mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-02 04:00:40 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e78ad7f819 | ||
|
|
fdb347fe8a | ||
|
|
8d0dce13c5 | ||
|
|
61c8435bd7 | ||
|
|
b313604bde | ||
|
|
5e6df93f64 | ||
|
|
920c0e55d5 | ||
|
|
7de2a2eb5e | ||
|
|
ce60acb294 | ||
|
|
4acdefd4b8 | ||
|
|
a16cb9c468 | ||
|
|
1bdaad9c59 | ||
|
|
db00a3d1ad | ||
|
|
d8b8ae634b | ||
|
|
cdb26aab3b | ||
|
|
f4db4469ba | ||
|
|
98453cab5d | ||
|
|
1fde71768d | ||
|
|
e00bdfe8d4 | ||
|
|
9e31e8d746 | ||
|
|
745927d674 | ||
|
|
dfd7785cc1 | ||
|
|
6e6b190da1 | ||
|
|
e03ba5685c | ||
|
|
1af17f3398 | ||
|
|
c964e11d38 | ||
|
|
8829aa5ce2 | ||
|
|
102274c668 | ||
|
|
38b766298e | ||
|
|
c0f106dc1e | ||
|
|
98a2cf9490 | ||
|
|
9203c2d684 | ||
|
|
719063e781 | ||
|
|
60c00fb5c2 | ||
|
|
1b277dcdd7 | ||
|
|
3065f3c00e |
@@ -0,0 +1,11 @@
|
||||
name: "Semantica CodeQL Config"
|
||||
|
||||
# Exclude auto-generated notebook exports and bundled third-party JS.
|
||||
# Files in cookbook/**/*.html are self-contained Plotly/MapLibre bundles
|
||||
# produced by Jupyter nbconvert — they embed minified third-party libraries
|
||||
# (Plotly, MapLibre GL JS) whose internal patterns trigger false-positive JS
|
||||
# alerts (js/incomplete-url-substring-sanitization, js/insecure-randomness,
|
||||
# js/prototype-pollution-utility). These are not application code.
|
||||
paths-ignore:
|
||||
- "cookbook/**/*.html"
|
||||
- "cookbook/**/*.js"
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
with:
|
||||
languages: python
|
||||
queries: security-and-quality
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
@@ -49,38 +50,19 @@ jobs:
|
||||
wait-for-processing: true
|
||||
continue-on-error: true
|
||||
|
||||
dismiss-fixed-alerts:
|
||||
name: Dismiss Fixed Security Alerts
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Dismiss resolved CodeQL alerts via API
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
FIXED_PATTERNS=(
|
||||
"py/clear-text-logging-sensitive-data"
|
||||
"py/incomplete-url-substring-sanitization"
|
||||
"actions/missing-workflow-permissions"
|
||||
)
|
||||
|
||||
# Fetch all open code scanning alerts
|
||||
ALERTS=$(gh api repos/$REPO/code-scanning/alerts \
|
||||
--jq '.[] | {number: .number, rule: .rule.id, state: .state}' \
|
||||
-X GET -f state=open -f per_page=100)
|
||||
|
||||
for PATTERN in "${FIXED_PATTERNS[@]}"; do
|
||||
ALERT_NUMS=$(echo "$ALERTS" | jq -r \
|
||||
"select(.rule == \"$PATTERN\") | .number")
|
||||
for NUM in $ALERT_NUMS; do
|
||||
echo "Dismissing alert #$NUM ($PATTERN) — fixed in security-enhancement PR"
|
||||
gh api repos/$REPO/code-scanning/alerts/$NUM \
|
||||
-X PATCH \
|
||||
-f state=dismissed \
|
||||
-f dismissed_reason="won't fix" \
|
||||
-f dismissed_comment="Fixed in PR security-enhancement: code changes remove the vulnerability. Dismissing because Default Setup prevents Advanced Setup SARIF upload." \
|
||||
&& echo " ✓ Alert #$NUM dismissed" \
|
||||
|| echo " ⚠ Could not dismiss alert #$NUM (may already be closed)"
|
||||
done
|
||||
done
|
||||
# NOTE: Auto-dismissal by rule-id is intentionally removed.
|
||||
# Dismissing every alert that matches a rule ID would silently suppress
|
||||
# future real vulnerabilities of the same type. The alerts below were
|
||||
# individually triaged and dismissed manually in the security-enhancement
|
||||
# PR (alerts #12–#18). New alerts must be reviewed and dismissed by hand,
|
||||
# or will auto-close when the underlying code no longer triggers them.
|
||||
#
|
||||
# If you need to dismiss a specific known-safe alert, pin its alert NUMBER
|
||||
# here and remove it once CodeQL stops reporting it naturally. Example:
|
||||
#
|
||||
# PINNED_ALERT_NUMBERS=(12 13 14 15 16 17 18)
|
||||
# for NUM in "${PINNED_ALERT_NUMBERS[@]}"; do
|
||||
# gh api repos/$REPO/code-scanning/alerts/$NUM \
|
||||
# -X PATCH -f state=dismissed -f dismissed_reason="false positive" \
|
||||
# -f dismissed_comment="<reason>"
|
||||
# done
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
python-version: '3.11'
|
||||
- run: pip install build
|
||||
- run: python -m build
|
||||
- uses: softprops/action-gh-release@v1
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/*
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Comment PR with Security Results
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -110,3 +110,6 @@ sample_data/
|
||||
|
||||
# Test Results
|
||||
test_results.txt
|
||||
|
||||
# Frontend build artifacts (generated by Vite — do not track in git)
|
||||
semantica/static/
|
||||
|
||||
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
|
||||
|
||||
**Critical**
|
||||
- **Eval injection eliminated** (`semantica/parse/media_parser.py`): Replaced `eval(stream.get("r_frame_rate", ...))` — which executed arbitrary Python from ffprobe JSON output — with a `_safe_parse_fps()` helper using `fractions.Fraction`. No code execution possible regardless of ffprobe output content. (CWE-95)
|
||||
- **Unsafe pickle deserialization replaced** (`semantica/context/agent_memory.py`): `AgentMemory.save()` / `load()` previously used `pickle.dump` / `pickle.load`, allowing RCE if an attacker could write the `.pkl` file. Replaced with `json.dump` / `json.load`. `MemoryItem.to_dict()` / `MemoryItem.from_dict()` added for safe round-trip serialization — `timestamp` via `isoformat()`, `embedding` dropped (not JSON-safe, regenerated on demand). Legacy `.pkl` files are detected and refused with a migration message. (CWE-502)
|
||||
|
||||
**High**
|
||||
- **SQL injection hardened** (`semantica/ingest/snowflake_ingestor.py`): `WHERE`, `ORDER BY`, `LIMIT`, and `OFFSET` were f-string interpolated directly into Snowflake queries. `LIMIT` / `OFFSET` now use parameterized `%s` placeholders; `ORDER BY` is validated against a strict `^[A-Za-z_][A-Za-z0-9_]*(\s+(ASC|DESC))?` regex; `WHERE` clauses containing semicolons are rejected before execution. (CWE-89)
|
||||
- **XXE protection added for RDF/XML parsing** (`semantica/explorer/utils/rdf_parser.py`): `rdflib.Graph().parse()` on XML-based RDF formats had no external-entity restrictions. Added `_safe_parse_rdf()` wrapper that calls `defusedxml.defuse_stdlib()` before parsing, neutralising Billion Laughs and local file-read XXE attacks. Falls back gracefully with a `UserWarning` if `defusedxml` is not installed. (CWE-611)
|
||||
- **Security headers and CORS added to main server** (`semantica/server.py`): No CORS policy, no response security headers, and raw `Exception` details were returned to clients. Added `CORSMiddleware` (origins from `SEMANTICA_CORS_ORIGINS` env var, defaults to `localhost` only); `_SecurityHeadersMiddleware` emitting `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, and HSTS (HTTPS only) on every response; global error handler that logs internally and returns a generic `500`. (CWE-346, CWE-200)
|
||||
- **CORS and WebSocket hardened in Explorer app** (`semantica/explorer/app.py`): `allow_methods=["*"]` and `allow_headers=["*"]` narrowed to `GET, POST, DELETE, OPTIONS` and `Content-Type, Authorization` only. `KeyError` / `ValueError` exception handlers now log the real message server-side and return generic text to clients. WebSocket messages larger than 64 KB trigger close with code `1009` (Message Too Big), preventing memory exhaustion via large frame injection. (CWE-346, CWE-400)
|
||||
|
||||
**Medium**
|
||||
- **Algorithm parameter validated by enum** (`semantica/explorer/routes/graph.py`): The `algorithm` query parameter previously accepted any string; unknown values silently fell back to BFS. Replaced with `_PathAlgorithm(str, Enum)` — FastAPI now returns `422 Unprocessable Entity` for any value other than `bfs` or `dijkstra`. (CWE-20)
|
||||
- **RDF upload extension allowlist** (`semantica/explorer/routes/vocabulary.py`): No file extension check was performed before reading RDF uploads. Extension is now validated against `{".ttl", ".rdf", ".owl", ".xml", ".jsonld", ".json-ld", ".json"}` before any content is read. (CWE-434)
|
||||
- **Prompt injection mitigated** (`semantica/semantic_extract/llm_extraction.py`): User-supplied text and entity/relation labels were embedded directly into LLM prompts via f-string interpolation — a crafted input like `"\n\nIgnore all above instructions..."` could override system instructions. All user-supplied content is now passed through `json.dumps()` before embedding, neutralising newlines, quotes, and instruction-override attempts. (CWE-1336)
|
||||
- **Dynamic `__import__()` removed** (`semantica/pipeline/pipeline_validator.py`): `__import__("collections").Counter(...)` replaced with a proper `from collections import Counter` module-level import. (CWE-95)
|
||||
- **ReDoS eliminated** (`semantica/explorer/routes/enrich.py`): `re.split(r"\s+AND\s+", antecedent_text, re.IGNORECASE)` on user-supplied rule strings could exhibit polynomial backtracking. Fixed by normalising whitespace first with `" ".join(text.split())` (no regex) then splitting on the literal `" AND "`. Closes CodeQL alert #12. (CWE-1333)
|
||||
- **Path traversal blocked** (`semantica/server.py`): SPA catch-all route used `STATIC_DIR / full_path` without validation. Added `Path.resolve()` + `relative_to()` check that returns `400 Bad Request` for any path that escapes `STATIC_DIR`. Closes CodeQL alerts #13 and #14. (CWE-22)
|
||||
|
||||
**Low**
|
||||
- **SPARQL result cap and timeout** (`semantica/explorer/routes/sparql.py`): SPARQL queries ran to completion with no row limit or timeout; expensive queries could exhaust memory or block indefinitely. Results are now capped at 5 000 rows; `asyncio.wait_for(..., timeout=30)` abandons the await after 30 seconds and returns a structured error response. A module-level `asyncio.Semaphore(4)` caps concurrent in-flight `graph.query` calls so that timed-out threads (which continue running in the pool) cannot crowd out other requests by exhausting executor workers. `SparqlResponse` gains a `truncated: bool` field so callers can detect a capped result set. (CWE-400)
|
||||
- **Import upload size limit and extension allowlist** (`semantica/explorer/routes/export_import.py`): No file size or type checks were enforced before reading import uploads. Extension is now validated against `{".json", ".csv"}` (the formats the handler actually parses — allowlist trimmed to match implementation); a hard 50 MB cap is enforced before content is read. (CWE-434)
|
||||
|
||||
**CodeQL / scanning infrastructure**
|
||||
- Added `.github/codeql/codeql-config.yml` with `paths-ignore` for `cookbook/**/*.html` and `cookbook/**/*.js`. Notebook-exported HTML files embed entire minified third-party bundles (Plotly + MapLibre GL JS v4.7.1) that triggered false-positive JS alerts #15–#18. The Advanced Setup workflow now references this config via `config-file:`. Closes CodeQL alerts #15, #16, #17, #18.
|
||||
- Removed blanket rule-ID auto-dismiss job from `.github/workflows/codeql.yml`. The previous job dismissed every open alert whose `rule.id` matched a fixed list on each `main` push — this would silently suppress any future real vulnerability of the same type. Replaced with a commented template for pinning specific alert numbers when manual dismissal is genuinely required.
|
||||
|
||||
- **Fix: Knowledge Explorer — blockers and security hardening** (PR #420 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- **Dockerfile**: Renamed `DockerFile` → `Dockerfile` (case-sensitive filename caused Docker build failures on Linux CI). Fixed `CMD` module path from the non-existent `semantica.server:app` to `semantica.explorer.app:app`, which caused the Docker image to crash on startup. Added `app = create_app()` at module level in `semantica/explorer/app.py` so uvicorn can reference the ASGI app instance directly.
|
||||
- **CORS hardening**: Changed `EXPLORER_CORS_ORIGINS` default from `"*"` to `"http://localhost:5173,http://127.0.0.1:5173"`. Any deployment that does not explicitly set the env var no longer exposes the API to all origins. The env var override continues to work as before.
|
||||
- **`get_ws_manager()` guard** (`semantica/explorer/dependencies.py`): `get_ws_manager()` now raises HTTP 503 if `app.state.ws_manager` is absent, matching the existing `get_session()` guard. Previously raised an unhandled `AttributeError` during testing or if the lifespan had not completed.
|
||||
- **SPARQL read-only enforcement** (`semantica/explorer/routes/sparql.py`): Added `_is_read_only_query()` regex guard — rejects any query whose first keyword is not `SELECT`, `ASK`, `CONSTRUCT`, or `DESCRIBE`. `INSERT`, `DELETE`, `UPDATE`, `LOAD`, and `DROP` queries now return a structured `SparqlResponse` error instead of being executed against the rdflib projection.
|
||||
- **Vocabulary import size limit** (`semantica/explorer/routes/vocabulary.py`): Added 10 MB upload cap for both file and raw-text payloads — returns HTTP 413 with a human-readable message before calling `parse_skos_file()`. Prevents memory exhaustion from oversized RDF uploads.
|
||||
- **JSON-LD format auto-detection** (`semantica/explorer/routes/vocabulary.py`): Import route now detects `.jsonld`, `.json-ld`, and `.json` file extensions and passes `"json-ld"` to `parse_skos_file()`. Previously these extensions fell through to `"turtle"` and failed silently despite JSON-LD being listed as a supported format.
|
||||
- **Annotation O(1) lookup** (`semantica/explorer/routes/annotations.py`, `semantica/explorer/session.py`): `create_annotation` previously called `get_annotations()` and scanned the full list to find the just-created annotation (O(N)). Added `GraphSession.get_annotation(annotation_id)` — O(1) dict lookup — and updated the route to use it directly.
|
||||
- **Self-loop guard in `batchMergeEdges`** (`semantica-explorer/src/store/graphStore.ts`): Added `if (source === target) continue` guard at the start of the loop. The Graphology instance is initialised with `allowSelfLoops: false`; a self-loop edge from reasoning inferences or provenance cycles previously caused an uncaught Graphology error that silently broke graph loading.
|
||||
- **Static build artifacts removed from git** (`.gitignore`): Added `semantica/static/` to `.gitignore` and removed all pre-built Vite bundles from version control. The Docker multi-stage build already rebuilds the frontend from source; committing minified bundles bloated repository history and caused merge conflicts on every frontend change.
|
||||
|
||||
- **Fix: TripletStore.store() IRI resolution regressions** (PR #447 follow-up by @KaifAhmad1):
|
||||
- Fixed `AttributeError` crash when entity or relationship IDs are non-string types (e.g. integers emitted by `GraphBuilder`). `_resolve_iri()` previously called `.startswith()` directly on the raw ID; it now coerces any value to `str()` at entry, restoring the implicit stringification that the old f-string URN minting provided.
|
||||
- Fixed W3C vocabulary prefixes (`owl:Thing`, `xsd:date`, `rdfs:Literal`, `skos:Concept`, etc.) being incorrectly re-namespaced under the ontology `base_uri` (e.g. `https://example.com/owl:Thing`) when `base_uri` was present. `_resolve_iri()` now consults a known-prefix expansion table (`xsd`, `rdf`, `rdfs`, `owl`, `skos`, `semantica`) before applying `base_uri`, matching the same prefix map already used in `BlazegraphStore`. Standard vocabulary IRIs are always expanded to their canonical W3C forms regardless of what `base_uri` is set to.
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/semantica-explorer
|
||||
|
||||
|
||||
COPY semantica-explorer/package.json semantica-explorer/package-lock.json* ./
|
||||
|
||||
|
||||
RUN npm install
|
||||
|
||||
|
||||
COPY semantica-explorer/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY semantica/ ./semantica/
|
||||
|
||||
COPY --from=frontend-builder /app/semantica/static ./semantica/static
|
||||
|
||||
RUN pip install --no-cache-dir ".[explorer]"
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "semantica.explorer.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,809 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[](https://colab.research.google.com/github/Hawksight-AI/semantica/blob/main/cookbook/advanced/14_Datalog_Style_Reasoning.ipynb)\n",
|
||||
"\n",
|
||||
"# Datalog-Style Reasoning\n",
|
||||
"\n",
|
||||
"End-to-end guide to Semantica's **`DatalogReasoner`** — a native bottom-up semi-naive fixpoint engine — wired together with `GraphBuilder`, `ContextGraph`, `GraphAnalyzer`, `ExplanationGenerator`, and the supporting data-classes (`DatalogFact`, `DatalogRule`, `InferenceResult`, `Rule`).\n",
|
||||
"\n",
|
||||
"## What you will build\n",
|
||||
"\n",
|
||||
"| Part | Topic | Key classes |\n",
|
||||
"|------|-------|-------------|\n",
|
||||
"| 1 | Core API & EDB/IDB concepts | `DatalogReasoner`, `DatalogFact`, `DatalogRule` |\n",
|
||||
"| 2 | KG → Datalog pipeline | `GraphBuilder`, `GraphAnalyzer`, `DatalogReasoner` |\n",
|
||||
"| 3 | ContextGraph integration | `ContextGraph`, `DatalogReasoner.load_from_graph()` |\n",
|
||||
"| 4 | RBAC access-control policy | `GraphBuilder`, `DatalogReasoner`, `ExplanationGenerator` |\n",
|
||||
"| 5 | Org hierarchy | `ContextGraph`, `DatalogReasoner`, `InferenceResult` |\n",
|
||||
"| 6 | Engine introspection | `DatalogFact`, `DatalogRule` internal state |\n",
|
||||
"\n",
|
||||
"**Related notebooks**\n",
|
||||
"- [08_Reasoning_and_Inference.ipynb](08_Reasoning_and_Inference.ipynb) — high-level `Reasoner` with IF/THEN syntax\n",
|
||||
"- [10_Temporal_Knowledge_Graphs.ipynb](10_Temporal_Knowledge_Graphs.ipynb) — temporal reasoning\n",
|
||||
"\n",
|
||||
"**Documentation**: [Reasoning API](https://semantica.readthedocs.io/reference/reasoning/) | [KG API](https://semantica.readthedocs.io/reference/kg/) | [Context API](https://semantica.readthedocs.io/reference/context/)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -qU semantica"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Reasoning ──────────────────────────────────────────────────────────────\n",
|
||||
"from semantica.reasoning import (\n",
|
||||
" DatalogReasoner, # native Datalog fixpoint engine\n",
|
||||
" DatalogFact, # frozen dataclass: predicate + args tuple\n",
|
||||
" DatalogRule, # dataclass: head + body (list[BodyAtom])\n",
|
||||
" ExplanationGenerator, # generates NL justifications\n",
|
||||
" InferenceResult, # result dataclass consumed by ExplanationGenerator\n",
|
||||
" Rule, # rule dataclass used by ExplanationGenerator\n",
|
||||
" RuleType, # enum: IMPLICATION | EQUIVALENCE | CONSTRAINT | TRANSFORMATION\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# ── Knowledge Graph ────────────────────────────────────────────────────────\n",
|
||||
"from semantica.kg import (\n",
|
||||
" GraphBuilder, # constructs KG dicts from entity+relationship sources\n",
|
||||
" GraphAnalyzer, # centrality, communities, connectivity, metrics\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# ── Context ────────────────────────────────────────────────────────────────\n",
|
||||
"from semantica.context import ContextGraph # in-memory graph: add_node/add_edge/find_*\n",
|
||||
"\n",
|
||||
"print(\"All Semantica classes imported successfully.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 1 — Core API: EDB Facts, IDB Rules, Fixpoint\n",
|
||||
"\n",
|
||||
"### Datalog in 30 seconds\n",
|
||||
"\n",
|
||||
"| Term | Meaning | Example |\n",
|
||||
"|------|---------|--------|\n",
|
||||
"| EDB (Extensional DB) | Ground facts you assert | `parent(tom, bob)` |\n",
|
||||
"| IDB (Intensional DB) | Facts derived by rules | `ancestor(tom, ann)` |\n",
|
||||
"| Rule (Horn clause) | If body → derive head | `ancestor(X,Y) :- parent(X,Y).` |\n",
|
||||
"| Variable | Uppercase, unified during eval | `X`, `Y`, `Role` |\n",
|
||||
"| Constant | Lowercase, matches literally | `tom`, `admin` |\n",
|
||||
"| Fixpoint | Iterate until no new facts appear | `DatalogReasoner.derive_all()` |\n",
|
||||
"\n",
|
||||
"### The canonical example — transitive ancestry"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Step 1: create engine ──────────────────────────────────────────────────\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"\n",
|
||||
"# ── Step 2: load EDB (ground facts) ───────────────────────────────────────\n",
|
||||
"# Syntax: predicate(constant1, constant2) — constants must be lowercase\n",
|
||||
"edb_facts = [\n",
|
||||
" \"parent(tom, bob)\",\n",
|
||||
" \"parent(bob, ann)\",\n",
|
||||
" \"parent(ann, pat)\",\n",
|
||||
"]\n",
|
||||
"for f in edb_facts:\n",
|
||||
" dr.add_fact(f)\n",
|
||||
"\n",
|
||||
"print(f\"EDB loaded: {len(dr._all_facts)} ground facts\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Step 3: add IDB rules (Horn clauses) ──────────────────────────────────\n",
|
||||
"# Syntax: head(Vars) :- body_atom1(Vars), body_atom2(Vars).\n",
|
||||
"# Variables start with uppercase; trailing '.' is optional\n",
|
||||
"dr.add_rule(\"ancestor(X, Y) :- parent(X, Y).\")\n",
|
||||
"dr.add_rule(\"ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).\") # recursive\n",
|
||||
"\n",
|
||||
"print(f\"Rules loaded: {len(dr._rules)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Step 4: fixpoint evaluation ────────────────────────────────────────────\n",
|
||||
"# derive_all() runs semi-naive bottom-up evaluation until no new facts appear\n",
|
||||
"all_facts: list[str] = dr.derive_all()\n",
|
||||
"\n",
|
||||
"ancestor_strs = sorted(f for f in all_facts if f.startswith(\"ancestor\"))\n",
|
||||
"print(f\"Derived {len(ancestor_strs)} ancestor facts:\")\n",
|
||||
"for f in ancestor_strs:\n",
|
||||
" print(\" \", f)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Step 5: query ──────────────────────────────────────────────────────────\n",
|
||||
"# Use '?varname' placeholders — query() auto-calls derive_all() if needed\n",
|
||||
"# Returns: list[dict] e.g. [{\"Y\": \"bob\"}, {\"Y\": \"ann\"}, {\"Y\": \"pat\"}]\n",
|
||||
"\n",
|
||||
"descendants = dr.query(\"ancestor(tom, ?Y)\")\n",
|
||||
"print(\"All descendants of tom:\", sorted(r[\"Y\"] for r in descendants))\n",
|
||||
"\n",
|
||||
"ancestors_of_pat = dr.query(\"ancestor(?X, pat)\")\n",
|
||||
"print(\"All ancestors of pat: \", sorted(r[\"X\"] for r in ancestors_of_pat))\n",
|
||||
"\n",
|
||||
"all_pairs = dr.query(\"ancestor(?X, ?Y)\")\n",
|
||||
"print(f\"\\nAll ancestor pairs ({len(all_pairs)}):\")\n",
|
||||
"for row in sorted(all_pairs, key=lambda r: (r[\"X\"], r[\"Y\"])):\n",
|
||||
" print(f\" {row['X']:6s} → {row['Y']}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 2 — GraphBuilder → DatalogReasoner Pipeline\n",
|
||||
"\n",
|
||||
"`GraphBuilder` constructs a structured `{\"entities\": [...], \"relationships\": [...]}` dict from your data. We then:\n",
|
||||
"\n",
|
||||
"1. Analyse the graph with `GraphAnalyzer` to understand structure.\n",
|
||||
"2. Feed `kg[\"relationships\"]` into `DatalogReasoner` as EDB facts.\n",
|
||||
"3. Apply recursive Datalog rules over the KG."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Build a software-dependency KG ────────────────────────────────────────\n",
|
||||
"entities = [\n",
|
||||
" {\"id\": \"pythonsdk\", \"name\": \"Python SDK\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"restapi\", \"name\": \"REST API\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"authservice\", \"name\": \"Auth Service\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"database\", \"name\": \"Database\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"dashboard\", \"name\": \"Dashboard\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"analytics\", \"name\": \"Analytics\", \"type\": \"Component\"},\n",
|
||||
"]\n",
|
||||
"relationships = [\n",
|
||||
" {\"source\": \"pythonsdk\", \"target\": \"restapi\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"restapi\", \"target\": \"authservice\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"authservice\", \"target\": \"database\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"dashboard\", \"target\": \"restapi\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"dashboard\", \"target\": \"analytics\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"analytics\", \"target\": \"database\", \"type\": \"depends_on\"},\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"# GraphBuilder validates, deduplicates, and packages the data\n",
|
||||
"builder = GraphBuilder(merge_entities=True, resolve_conflicts=False)\n",
|
||||
"kg = builder.build([{\"entities\": entities, \"relationships\": relationships}])\n",
|
||||
"\n",
|
||||
"print(f\"KG built — entities: {len(kg['entities'])}, relationships: {len(kg['relationships'])}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Analyse the graph structure before reasoning ───────────────────────────\n",
|
||||
"# GraphAnalyzer provides centrality, communities, connectivity, and metrics\n",
|
||||
"analyzer = GraphAnalyzer()\n",
|
||||
"metrics = analyzer.compute_metrics(graph=kg)\n",
|
||||
"\n",
|
||||
"print(\"Graph structure:\")\n",
|
||||
"print(f\" Nodes : {metrics['num_nodes']}\")\n",
|
||||
"print(f\" Edges : {metrics['num_edges']}\")\n",
|
||||
"if \"density\" in metrics:\n",
|
||||
" print(f\" Density : {metrics['density']:.3f}\")\n",
|
||||
"if \"is_connected\" in metrics:\n",
|
||||
" print(f\" Connected : {metrics['is_connected']}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Load KG relationships as EDB facts ────────────────────────────────────\n",
|
||||
"# GraphBuilder output dicts use the same source/target/type shape that\n",
|
||||
"# DatalogReasoner.add_fact() natively understands\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"\n",
|
||||
"for rel in kg[\"relationships\"]:\n",
|
||||
" dr.add_fact(rel) # dict path: {\"source\": ..., \"target\": ..., \"type\": ...}\n",
|
||||
"\n",
|
||||
"print(f\"EDB loaded: {len(dr._all_facts)} dependency facts\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Transitive dependency closure ─────────────────────────────────────────\n",
|
||||
"# 'depends_on' is the predicate name that add_fact inferred from 'type'\n",
|
||||
"dr.add_rule(\"transitive_dep(X, Y) :- depends_on(X, Y).\")\n",
|
||||
"dr.add_rule(\"transitive_dep(X, Y) :- depends_on(X, Z), transitive_dep(Z, Y).\")\n",
|
||||
"\n",
|
||||
"dr.derive_all()\n",
|
||||
"\n",
|
||||
"# Everything that transitively depends on the database\n",
|
||||
"db_deps = sorted(r[\"X\"] for r in dr.query(\"transitive_dep(?X, database)\"))\n",
|
||||
"print(\"Components that transitively depend on Database:\")\n",
|
||||
"for c in db_deps:\n",
|
||||
" print(\" \", c)\n",
|
||||
"\n",
|
||||
"# What does pythonsdk transitively depend on?\n",
|
||||
"sdk_chain = sorted(r[\"Y\"] for r in dr.query(\"transitive_dep(pythonsdk, ?Y)\"))\n",
|
||||
"print(f\"\\nPython SDK full dependency chain: {sdk_chain}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 3 — ContextGraph + `load_from_graph()`\n",
|
||||
"\n",
|
||||
"`DatalogReasoner.load_from_graph(graph)` accepts any `ContextGraph` directly: it calls `graph.find_edges()` and `graph.find_nodes()` and converts each result into EDB facts automatically."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Build an in-memory ContextGraph ───────────────────────────────────────\n",
|
||||
"# ContextGraph.add_node / add_edge are the canonical way to build in-memory KGs\n",
|
||||
"cg = ContextGraph()\n",
|
||||
"\n",
|
||||
"# Nodes\n",
|
||||
"for person in [\"alice\", \"bob\", \"carol\", \"dave\", \"eve\"]:\n",
|
||||
" cg.add_node(person, node_type=\"person\", name=person.capitalize())\n",
|
||||
"\n",
|
||||
"# Directed \"follows\" edges\n",
|
||||
"for src, dst in [(\"alice\", \"bob\"), (\"bob\", \"carol\"), (\"carol\", \"dave\"), (\"alice\", \"eve\"), (\"eve\", \"carol\")]:\n",
|
||||
" cg.add_edge(src, dst, edge_type=\"follows\")\n",
|
||||
"\n",
|
||||
"# Verify the graph built correctly\n",
|
||||
"nodes = cg.find_nodes(node_type=\"person\")\n",
|
||||
"edges = cg.find_edges(edge_type=\"follows\")\n",
|
||||
"print(f\"ContextGraph — nodes: {len(nodes)}, edges: {len(edges)}\")\n",
|
||||
"print(\"Edges:\", [(e.get(\"source\", e.get(\"source_id\")), e.get(\"target\", e.get(\"target_id\"))) for e in edges])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── load_from_graph() ingests the ContextGraph directly ───────────────────\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"n_loaded = dr.load_from_graph(cg) # calls cg.find_edges() + cg.find_nodes() internally\n",
|
||||
"print(f\"Facts loaded from ContextGraph: {n_loaded}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Influence reach via transitive 'follows' ──────────────────────────────\n",
|
||||
"dr.add_rule(\"influence(X, Y) :- follows(X, Y).\")\n",
|
||||
"dr.add_rule(\"influence(X, Y) :- follows(X, Z), influence(Z, Y).\")\n",
|
||||
"\n",
|
||||
"dr.derive_all()\n",
|
||||
"\n",
|
||||
"# Who can alice reach?\n",
|
||||
"alice_reach = sorted(r[\"Y\"] for r in dr.query(\"influence(alice, ?Y)\"))\n",
|
||||
"print(f\"Alice's influence reach : {alice_reach}\")\n",
|
||||
"\n",
|
||||
"# Who can reach dave?\n",
|
||||
"reach_dave = sorted(r[\"X\"] for r in dr.query(\"influence(?X, dave)\"))\n",
|
||||
"print(f\"Who can influence dave : {reach_dave}\")\n",
|
||||
"\n",
|
||||
"# Full influence matrix\n",
|
||||
"all_influence = dr.query(\"influence(?X, ?Y)\")\n",
|
||||
"print(f\"\\nTotal influence pairs: {len(all_influence)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 4 — RBAC Access-Control Policy\n",
|
||||
"\n",
|
||||
"We model a role-based access-control (RBAC) system:\n",
|
||||
"\n",
|
||||
"1. Use `GraphBuilder` to build a structured KG of users, roles, and permissions.\n",
|
||||
"2. Load it into `DatalogReasoner` for policy inference.\n",
|
||||
"3. Use `ExplanationGenerator` to produce audit-ready NL justifications."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Build RBAC graph with GraphBuilder ────────────────────────────────────\n",
|
||||
"rbac_entities = [\n",
|
||||
" # Users\n",
|
||||
" {\"id\": \"alice\", \"type\": \"User\", \"name\": \"Alice\"},\n",
|
||||
" {\"id\": \"bob\", \"type\": \"User\", \"name\": \"Bob\"},\n",
|
||||
" {\"id\": \"carol\", \"type\": \"User\", \"name\": \"Carol\"},\n",
|
||||
" {\"id\": \"dave\", \"type\": \"User\", \"name\": \"Dave\"},\n",
|
||||
" # Roles\n",
|
||||
" {\"id\": \"admin\", \"type\": \"Role\", \"name\": \"Administrator\"},\n",
|
||||
" {\"id\": \"editor\", \"type\": \"Role\", \"name\": \"Editor\"},\n",
|
||||
" {\"id\": \"viewer\", \"type\": \"Role\", \"name\": \"Viewer\"},\n",
|
||||
" # Permissions\n",
|
||||
" {\"id\": \"read\", \"type\": \"Permission\"},\n",
|
||||
" {\"id\": \"write\", \"type\": \"Permission\"},\n",
|
||||
" {\"id\": \"delete\", \"type\": \"Permission\"},\n",
|
||||
" {\"id\": \"manage_users\", \"type\": \"Permission\"},\n",
|
||||
"]\n",
|
||||
"rbac_relationships = [\n",
|
||||
" # User → Role assignments\n",
|
||||
" {\"source\": \"alice\", \"target\": \"admin\", \"type\": \"has_role\"},\n",
|
||||
" {\"source\": \"bob\", \"target\": \"editor\", \"type\": \"has_role\"},\n",
|
||||
" {\"source\": \"carol\", \"target\": \"viewer\", \"type\": \"has_role\"},\n",
|
||||
" {\"source\": \"dave\", \"target\": \"editor\", \"type\": \"has_role\"},\n",
|
||||
" # Role hierarchy (admin inherits from editor, editor from viewer)\n",
|
||||
" {\"source\": \"admin\", \"target\": \"editor\", \"type\": \"role_inherits\"},\n",
|
||||
" {\"source\": \"editor\", \"target\": \"viewer\", \"type\": \"role_inherits\"},\n",
|
||||
" # Role → Permission grants\n",
|
||||
" {\"source\": \"viewer\", \"target\": \"read\", \"type\": \"role_has_perm\"},\n",
|
||||
" {\"source\": \"editor\", \"target\": \"write\", \"type\": \"role_has_perm\"},\n",
|
||||
" {\"source\": \"admin\", \"target\": \"delete\", \"type\": \"role_has_perm\"},\n",
|
||||
" {\"source\": \"admin\", \"target\": \"manage_users\", \"type\": \"role_has_perm\"},\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"builder = GraphBuilder(merge_entities=True, resolve_conflicts=False)\n",
|
||||
"rbac_kg = builder.build([{\"entities\": rbac_entities, \"relationships\": rbac_relationships}])\n",
|
||||
"\n",
|
||||
"print(f\"RBAC KG — entities: {len(rbac_kg['entities'])}, relationships: {len(rbac_kg['relationships'])}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Analyse RBAC graph structure ──────────────────────────────────────────\n",
|
||||
"analyzer = GraphAnalyzer()\n",
|
||||
"metrics = analyzer.compute_metrics(graph=rbac_kg)\n",
|
||||
"centrality = analyzer.calculate_centrality(rbac_kg, centrality_type=\"degree\")\n",
|
||||
"\n",
|
||||
"print(f\"RBAC graph — {metrics['num_nodes']} nodes, {metrics['num_edges']} edges\")\n",
|
||||
"if isinstance(centrality, dict) and \"degree\" in centrality:\n",
|
||||
" top = sorted(centrality[\"degree\"].items(), key=lambda x: x[1], reverse=True)[:3]\n",
|
||||
" print(\"Top-3 nodes by degree centrality:\", top)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Load RBAC KG into DatalogReasoner ────────────────────────────────────\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"\n",
|
||||
"for rel in rbac_kg[\"relationships\"]:\n",
|
||||
" dr.add_fact(rel) # {source, target, type} → predicate(source, target)\n",
|
||||
"\n",
|
||||
"# ── IDB rules: transitive role hierarchy ─────────────────────────────────\n",
|
||||
"dr.add_rule(\"effective_role(R, R2) :- role_inherits(R, R2).\")\n",
|
||||
"dr.add_rule(\"effective_role(R, R2) :- role_inherits(R, Z), effective_role(Z, R2).\")\n",
|
||||
"\n",
|
||||
"# ── IDB rules: inherited permissions ─────────────────────────────────────\n",
|
||||
"dr.add_rule(\"role_can(R, P) :- role_has_perm(R, P).\")\n",
|
||||
"dr.add_rule(\"role_can(R, P) :- effective_role(R, R2), role_has_perm(R2, P).\")\n",
|
||||
"\n",
|
||||
"# ── IDB rules: user effective permissions ────────────────────────────────\n",
|
||||
"dr.add_rule(\"can(U, P) :- has_role(U, R), role_can(R, P).\")\n",
|
||||
"\n",
|
||||
"dr.derive_all()\n",
|
||||
"\n",
|
||||
"print(\"User permissions derived via role-hierarchy inference:\")\n",
|
||||
"for user in [\"alice\", \"bob\", \"carol\", \"dave\"]:\n",
|
||||
" perms = sorted(r[\"P\"] for r in dr.query(f\"can({user}, ?P)\"))\n",
|
||||
" print(f\" {user:6s}: {perms}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── ExplanationGenerator — audit-ready NL justification ──────────────────\n",
|
||||
"# ExplanationGenerator works with InferenceResult objects.\n",
|
||||
"# We construct one manually to represent a derived Datalog conclusion.\n",
|
||||
"\n",
|
||||
"explainer = ExplanationGenerator(detail_level=\"detailed\")\n",
|
||||
"\n",
|
||||
"# Build the Rule object that represents the permission derivation chain\n",
|
||||
"perm_rule = Rule(\n",
|
||||
" rule_id=\"rbac_perm_chain\",\n",
|
||||
" name=\"RBAC permission via role hierarchy\",\n",
|
||||
" conditions=[\"has_role(alice, admin)\", \"effective_role(admin, viewer)\", \"role_has_perm(viewer, read)\"],\n",
|
||||
" conclusion=\"can(alice, read)\",\n",
|
||||
" rule_type=RuleType.IMPLICATION,\n",
|
||||
" confidence=1.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Build InferenceResult representing the Datalog conclusion\n",
|
||||
"result = InferenceResult(\n",
|
||||
" conclusion=\"can(alice, read)\",\n",
|
||||
" rule_used=perm_rule,\n",
|
||||
" premises=[\n",
|
||||
" \"has_role(alice, admin)\",\n",
|
||||
" \"role_inherits(admin, editor)\",\n",
|
||||
" \"role_inherits(editor, viewer)\",\n",
|
||||
" \"role_has_perm(viewer, read)\",\n",
|
||||
" ],\n",
|
||||
" confidence=1.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Generate NL explanation\n",
|
||||
"explanation = explainer.generate_explanation(result)\n",
|
||||
"print(\"Explanation type :\", explanation.explanation_type)\n",
|
||||
"print(\"Conclusion :\", explanation.conclusion)\n",
|
||||
"print(\"Natural language :\", explanation.natural_language)\n",
|
||||
"print(\"Reasoning steps :\", len(explanation.reasoning_path.steps))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Inverse queries ───────────────────────────────────────────────────────\n",
|
||||
"deleters = sorted(r[\"U\"] for r in dr.query(\"can(?U, delete)\"))\n",
|
||||
"print(\"Who can delete:\", deleters)\n",
|
||||
"\n",
|
||||
"writers = sorted(r[\"U\"] for r in dr.query(\"can(?U, write)\"))\n",
|
||||
"print(\"Who can write: \", writers)\n",
|
||||
"\n",
|
||||
"# All (user, permission) pairs — full policy matrix\n",
|
||||
"all_caps = dr.query(\"can(?U, ?P)\")\n",
|
||||
"print(f\"\\nTotal (user, permission) pairs: {len(all_caps)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 5 — Organisation Hierarchy with ContextGraph\n",
|
||||
"\n",
|
||||
"We model a company org-chart using `ContextGraph` and derive:\n",
|
||||
"- `manages(M, E)` — direct and transitive management\n",
|
||||
"- `skip_level(M, E)` — two hops up the chain\n",
|
||||
"- `same_team(X, Y)` — shared team membership"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── ContextGraph: org chart ────────────────────────────────────────────────\n",
|
||||
"org = ContextGraph()\n",
|
||||
"\n",
|
||||
"# Add employees as nodes with metadata\n",
|
||||
"staff = [\n",
|
||||
" (\"eng1\", \"engineer\", \"backend\"),\n",
|
||||
" (\"eng2\", \"engineer\", \"backend\"),\n",
|
||||
" (\"eng3\", \"engineer\", \"frontend\"),\n",
|
||||
" (\"techlead\", \"lead\", \"engineering\"),\n",
|
||||
" (\"design1\", \"designer\", \"ux\"),\n",
|
||||
" (\"design2\", \"designer\", \"ux\"),\n",
|
||||
" (\"designlead\",\"lead\", \"design\"),\n",
|
||||
" (\"vpeng\", \"vp\", \"engineering\"),\n",
|
||||
" (\"cto\", \"executive\", \"leadership\"),\n",
|
||||
"]\n",
|
||||
"for emp_id, role, team in staff:\n",
|
||||
" org.add_node(emp_id, node_type=\"employee\", role=role, team=team)\n",
|
||||
"\n",
|
||||
"# Reporting lines\n",
|
||||
"reports_to = [\n",
|
||||
" (\"eng1\", \"techlead\"), (\"eng2\", \"techlead\"), (\"eng3\", \"techlead\"),\n",
|
||||
" (\"techlead\", \"vpeng\"),\n",
|
||||
" (\"design1\", \"designlead\"), (\"design2\", \"designlead\"),\n",
|
||||
" (\"designlead\", \"vpeng\"),\n",
|
||||
" (\"vpeng\", \"cto\"),\n",
|
||||
"]\n",
|
||||
"for employee, manager in reports_to:\n",
|
||||
" org.add_edge(employee, manager, edge_type=\"reports_to\")\n",
|
||||
"\n",
|
||||
"# Team membership edges\n",
|
||||
"teams = [\n",
|
||||
" (\"eng1\", \"backend\"), (\"eng2\", \"backend\"), (\"eng3\", \"frontend\"),\n",
|
||||
" (\"design1\", \"ux\"), (\"design2\", \"ux\"),\n",
|
||||
"]\n",
|
||||
"for emp, team in teams:\n",
|
||||
" org.add_edge(emp, team, edge_type=\"in_team\")\n",
|
||||
" if not org.find_nodes(node_type=\"team\"):\n",
|
||||
" org.add_node(team, node_type=\"team\")\n",
|
||||
"\n",
|
||||
"print(f\"ContextGraph — nodes: {len(org.find_nodes())}, edges: {len(org.find_edges())}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Load org chart into DatalogReasoner ───────────────────────────────────\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"n = dr.load_from_graph(org) # uses org.find_edges() + org.find_nodes()\n",
|
||||
"print(f\"Facts loaded via load_from_graph(): {n}\")\n",
|
||||
"\n",
|
||||
"# ── IDB rules ─────────────────────────────────────────────────────────────\n",
|
||||
"# Transitive management chain\n",
|
||||
"dr.add_rule(\"manages(M, E) :- reports_to(E, M).\")\n",
|
||||
"dr.add_rule(\"manages(M, E) :- reports_to(E, Z), manages(M, Z).\")\n",
|
||||
"\n",
|
||||
"# Skip-level: exactly two reporting hops\n",
|
||||
"dr.add_rule(\"skip_level(M, E) :- reports_to(E, Z), reports_to(Z, M).\")\n",
|
||||
"\n",
|
||||
"# Same team\n",
|
||||
"dr.add_rule(\"same_team(X, Y) :- in_team(X, T), in_team(Y, T).\")\n",
|
||||
"\n",
|
||||
"dr.derive_all()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Query org hierarchy ────────────────────────────────────────────────────\n",
|
||||
"# Everyone under CTO\n",
|
||||
"under_cto = sorted(r[\"E\"] for r in dr.query(\"manages(cto, ?E)\"))\n",
|
||||
"print(f\"CTO manages ({len(under_cto)} people): {under_cto}\")\n",
|
||||
"\n",
|
||||
"# VP Eng's direct + indirect reports\n",
|
||||
"under_vp = sorted(r[\"E\"] for r in dr.query(\"manages(vpeng, ?E)\"))\n",
|
||||
"print(f\"VP Eng manages : {under_vp}\")\n",
|
||||
"\n",
|
||||
"# Skip-level reports to CTO (people two hops below CTO)\n",
|
||||
"skip = sorted(r[\"E\"] for r in dr.query(\"skip_level(cto, ?E)\"))\n",
|
||||
"print(f\"CTO skip-level reports : {skip}\")\n",
|
||||
"\n",
|
||||
"# eng1's teammates\n",
|
||||
"mates = [r[\"Y\"] for r in dr.query(\"same_team(eng1, ?Y)\") if r[\"Y\"] != \"eng1\"]\n",
|
||||
"print(f\"eng1's teammates : {sorted(mates)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Build an InferenceResult and explain an org query ────────────────────\n",
|
||||
"explainer = ExplanationGenerator(detail_level=\"verbose\")\n",
|
||||
"\n",
|
||||
"mgmt_rule = Rule(\n",
|
||||
" rule_id=\"transitive_manages\",\n",
|
||||
" name=\"Transitive management chain\",\n",
|
||||
" conditions=[\"reports_to(eng1, techlead)\", \"manages(vpeng, techlead)\"],\n",
|
||||
" conclusion=\"manages(vpeng, eng1)\",\n",
|
||||
" rule_type=RuleType.IMPLICATION,\n",
|
||||
" confidence=1.0,\n",
|
||||
")\n",
|
||||
"result = InferenceResult(\n",
|
||||
" conclusion=\"manages(vpeng, eng1)\",\n",
|
||||
" rule_used=mgmt_rule,\n",
|
||||
" premises=[\"reports_to(eng1, techlead)\", \"reports_to(techlead, vpeng)\"],\n",
|
||||
" confidence=1.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"exp = explainer.generate_explanation(result)\n",
|
||||
"print(exp.natural_language)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 6 — Engine Introspection: DatalogFact & DatalogRule\n",
|
||||
"\n",
|
||||
"After reasoning, the engine's internal state is fully accessible via `DatalogFact` and `DatalogRule` data-classes. Use this for auditing, debugging, or downstream export."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Inspect DatalogRule objects ────────────────────────────────────────────\n",
|
||||
"# dr._rules → List[DatalogRule]\n",
|
||||
"# DatalogRule.head_predicate, .head_args, .body (body = List[BodyAtom])\n",
|
||||
"print(\"Rules in engine:\")\n",
|
||||
"for rule in dr._rules:\n",
|
||||
" body_str = \", \".join(\n",
|
||||
" f\"{atom.predicate}({', '.join(atom.args)})\"\n",
|
||||
" for atom in rule.body\n",
|
||||
" )\n",
|
||||
" head_str = f\"{rule.head_predicate}({', '.join(rule.head_args)})\"\n",
|
||||
" print(f\" {head_str} :- {body_str}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Inspect DatalogFact objects ────────────────────────────────────────────\n",
|
||||
"# dr._all_facts → Set[DatalogFact] (EDB + IDB combined after derive_all)\n",
|
||||
"# dr._fact_index → Dict[predicate, Set[DatalogFact]]\n",
|
||||
"\n",
|
||||
"from collections import Counter\n",
|
||||
"\n",
|
||||
"# Count facts per predicate\n",
|
||||
"predicate_counts = Counter(f.predicate for f in dr._all_facts)\n",
|
||||
"print(\"Facts per predicate (EDB + derived IDB):\")\n",
|
||||
"for pred, count in sorted(predicate_counts.items()):\n",
|
||||
" print(f\" {pred:20s}: {count}\")\n",
|
||||
"print(f\"\\n TOTAL: {len(dr._all_facts)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Separate EDB from IDB ─────────────────────────────────────────────────\n",
|
||||
"# EDB predicates are the ones we added via add_fact (not derived by rules)\n",
|
||||
"idb_predicates = {rule.head_predicate for rule in dr._rules}\n",
|
||||
"edb_predicates = {f.predicate for f in dr._all_facts} - idb_predicates\n",
|
||||
"\n",
|
||||
"print(f\"EDB predicates (base facts) : {sorted(edb_predicates)}\")\n",
|
||||
"print(f\"IDB predicates (derived) : {sorted(idb_predicates)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Sample DatalogFact structure ──────────────────────────────────────────\n",
|
||||
"# DatalogFact is a frozen dataclass: predicate: str, args: Tuple[str, ...]\n",
|
||||
"manages_facts = sorted(dr._fact_index.get(\"manages\", []), key=lambda f: f.args)\n",
|
||||
"print(f\"First 5 'manages' DatalogFact objects ({len(manages_facts)} total):\")\n",
|
||||
"for fact in manages_facts[:5]:\n",
|
||||
" # Access predicate and args directly from the dataclass\n",
|
||||
" print(f\" DatalogFact(predicate={fact.predicate!r}, args={fact.args})\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── clear() resets the engine completely ─────────────────────────────────\n",
|
||||
"print(f\"Facts before clear(): {len(dr._all_facts)}\")\n",
|
||||
"dr.clear()\n",
|
||||
"print(f\"Facts after clear(): {len(dr._all_facts)}\")\n",
|
||||
"print(f\"Rules after clear(): {len(dr._rules)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## API Summary\n",
|
||||
"\n",
|
||||
"### DatalogReasoner\n",
|
||||
"\n",
|
||||
"| Method | Input | Output | Notes |\n",
|
||||
"|--------|-------|--------|-------|\n",
|
||||
"| `add_fact(f)` | `str` or `dict` | `None` | string: `\"pred(a, b)\"` · dict: `{source, target, type}` |\n",
|
||||
"| `add_rule(s)` | `str` | `None` | Horn clause: `\"head(X) :- body(X, Y).\"` |\n",
|
||||
"| `derive_all()` | — | `list[str]` | semi-naive fixpoint; idempotent |\n",
|
||||
"| `query(pat)` | `str` | `list[dict]` | `\"pred(a, ?Y)\"` → `[{\"Y\": ...}]` |\n",
|
||||
"| `load_from_graph(g)` | `ContextGraph` | `int` | facts loaded count |\n",
|
||||
"| `clear()` | — | `None` | resets engine |\n",
|
||||
"\n",
|
||||
"### Syntax rules\n",
|
||||
"\n",
|
||||
"| Item | Rule | Example |\n",
|
||||
"|------|------|---------|\n",
|
||||
"| Variable | Starts **uppercase** | `X`, `Role`, `Parent` |\n",
|
||||
"| Constant | All **lowercase** | `tom`, `admin`, `database` |\n",
|
||||
"| Query var | Prefix `?` | `?X`, `?Y`, `?Role` |\n",
|
||||
"| Rule body | `:-` separator, comma between atoms | `head(X) :- a(X, Z), b(Z, Y).` |\n",
|
||||
"\n",
|
||||
"### Class map\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"GraphBuilder.build() → kg dict {entities, relationships}\n",
|
||||
" ↓ kg[\"relationships\"] → dr.add_fact(rel)\n",
|
||||
" \n",
|
||||
"ContextGraph.add_node/add_edge → in-memory graph\n",
|
||||
" ↓ dr.load_from_graph(cg)\n",
|
||||
" \n",
|
||||
"DatalogReasoner.add_rule() → Horn clause rules\n",
|
||||
"DatalogReasoner.derive_all() → semi-naive fixpoint\n",
|
||||
"DatalogReasoner.query() → result rows\n",
|
||||
" ↓ build InferenceResult\n",
|
||||
" \n",
|
||||
"ExplanationGenerator → natural language justification\n",
|
||||
"GraphAnalyzer → graph structure metrics pre/post reasoning\n",
|
||||
"DatalogFact / DatalogRule → introspect engine state\n",
|
||||
"```"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.10.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,75 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
||||
|
||||
Note: This will impact Vite dev & build performances.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>semantica-explorer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3945
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "semantica-explorer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@sigma/node-border": "^3.0.0",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-communities-louvain": "^2.0.2",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-metrics": "^2.4.0",
|
||||
"graphology-shortest-path": "^2.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"sigma": "^3.0.2",
|
||||
"vis-data": "^8.0.3",
|
||||
"vis-timeline": "^8.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@rolldown/plugin-babel": "^0.2.1",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,432 @@
|
||||
import { lazy, Suspense, useState, type ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Database, FileSearch, GitBranchPlus, Scale, Settings2 } from 'lucide-react';
|
||||
|
||||
const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
|
||||
const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
|
||||
const GraphWorkspace = lazy(() => import('./workspaces/GraphWorkspace/GraphWorkspace').then((module) => ({ default: module.GraphWorkspace })));
|
||||
const ImportExportWorkspace = lazy(() => import('./workspaces/ImportExportWorkspace/ImportExportWorkspace').then((module) => ({ default: module.ImportExportWorkspace })));
|
||||
const LineageDiagram = lazy(() => import('./workspaces/LineageWorkspace/LineageDiagram').then((module) => ({ default: module.LineageDiagram })));
|
||||
const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace })));
|
||||
const SparqlWorkspace = lazy(() => import('./workspaces/SparqlWorkspace/SparqlWorkspace').then((module) => ({ default: module.SparqlWorkspace })));
|
||||
const VocabularyWorkspace = lazy(() => import('./workspaces/VocabularyWorkspace/VocabularyWorkspace').then((module) => ({ default: module.VocabularyWorkspace })));
|
||||
|
||||
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type ExploreView = 'graph' | 'vocabulary';
|
||||
type AnalyzeView = 'sparql' | 'reasoning';
|
||||
type EnrichView = 'import' | 'merge';
|
||||
|
||||
type NavItem = {
|
||||
id: WorkspaceId;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: typeof Database;
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'explore', label: 'Explore', hint: 'Graph and vocabulary browsing', icon: Database },
|
||||
{ id: 'analyze', label: 'Analyze', hint: 'Query and inspect the dataset', icon: FileSearch },
|
||||
{ id: 'decisions', label: 'Decisions', hint: 'Decision chains and precedent review', icon: Scale },
|
||||
{ id: 'enrich', label: 'Enrich', hint: 'Import, export, and merge workflows', icon: GitBranchPlus },
|
||||
{ id: 'manage', label: 'Manage', hint: 'Lineage and governance tooling', icon: Settings2 },
|
||||
];
|
||||
|
||||
const shellStyles = `
|
||||
:root {
|
||||
--app-bg: #07111f;
|
||||
--panel-bg: rgba(7, 17, 31, 0.82);
|
||||
--panel-border: rgba(140, 192, 255, 0.14);
|
||||
--text-main: #ebf3ff;
|
||||
--text-muted: #8fa8c6;
|
||||
--accent: #4aa3ff;
|
||||
--accent-strong: #7fd0ff;
|
||||
--warm: #f2b66d;
|
||||
--success: #4cc38a;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
color: var(--text-main);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(74, 163, 255, 0.12), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(242, 182, 109, 0.08), transparent 26%),
|
||||
linear-gradient(180deg, #091322 0%, #050b15 100%);
|
||||
font-family: "Segoe UI", "SF Pro Display", sans-serif;
|
||||
}
|
||||
|
||||
.app-rail {
|
||||
width: 88px;
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border-right: 1px solid var(--panel-border);
|
||||
background: rgba(3, 9, 18, 0.92);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand-pill {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-main);
|
||||
background: linear-gradient(135deg, rgba(74, 163, 255, 0.22), rgba(127, 208, 255, 0.08));
|
||||
border: 1px solid rgba(127, 208, 255, 0.18);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 18px;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
color: var(--text-main);
|
||||
background: rgba(74, 163, 255, 0.08);
|
||||
border-color: rgba(74, 163, 255, 0.12);
|
||||
}
|
||||
|
||||
.nav-button[data-active='true'] {
|
||||
color: var(--text-main);
|
||||
background: linear-gradient(180deg, rgba(74, 163, 255, 0.18), rgba(74, 163, 255, 0.08));
|
||||
border-color: rgba(127, 208, 255, 0.22);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
background: linear-gradient(180deg, rgba(7, 17, 31, 0.94), rgba(7, 17, 31, 0.82));
|
||||
backdrop-filter: blur(18px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.workspace-header--compact {
|
||||
min-height: 60px;
|
||||
padding: 10px 18px;
|
||||
background: linear-gradient(180deg, rgba(7, 17, 31, 0.92), rgba(7, 17, 31, 0.72));
|
||||
}
|
||||
|
||||
.workspace-header--compact .workspace-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.workspace-header--compact .workspace-subtitle {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.workspace-header-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.workspace-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(74, 163, 255, 0.08);
|
||||
border: 1px solid rgba(127, 208, 255, 0.14);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-kicker::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--accent-strong), var(--warm));
|
||||
box-shadow: 0 0 14px rgba(127, 208, 255, 0.45);
|
||||
}
|
||||
|
||||
.workspace-title-block {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.workspace-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workspace-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workspace-tab {
|
||||
border: 1px solid rgba(127, 208, 255, 0.18);
|
||||
background: rgba(9, 19, 34, 0.56);
|
||||
color: var(--text-muted);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: 160ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-tab[data-active='true'] {
|
||||
color: var(--text-main);
|
||||
background: rgba(74, 163, 255, 0.16);
|
||||
border-color: rgba(127, 208, 255, 0.3);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.workspace-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-loading {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
background: linear-gradient(180deg, rgba(7, 17, 31, 0.8), rgba(5, 11, 21, 0.92));
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workspace-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-height: auto;
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.workspace-header-main {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.workspace-subtitle {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.workspace-tabs {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function WorkspaceShell({
|
||||
title,
|
||||
subtitle,
|
||||
tabs,
|
||||
compact = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
tabs?: ReactNode;
|
||||
compact?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="workspace-shell">
|
||||
<header className={`workspace-header${compact ? " workspace-header--compact" : ""}`}>
|
||||
<div className="workspace-header-main">
|
||||
<div className="workspace-kicker">Workspace</div>
|
||||
<div className="workspace-title-block">
|
||||
<h1 className="workspace-title">{title}</h1>
|
||||
{subtitle ? <div className="workspace-subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{tabs ? <div className="workspace-tabs">{tabs}</div> : null}
|
||||
</header>
|
||||
<div className="workspace-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceFallback() {
|
||||
return <div className="workspace-loading">Loading workspace…</div>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('explore');
|
||||
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
||||
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||
|
||||
const renderWorkspace = () => {
|
||||
if (activeWorkspace === 'explore') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Explore"
|
||||
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
|
||||
compact
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={exploreView === 'graph'} onClick={() => setExploreView('graph')}>
|
||||
Network Explorer
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={exploreView === 'vocabulary'} onClick={() => setExploreView('vocabulary')}>
|
||||
Vocabulary Browser
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
{exploreView === 'graph' ? <GraphWorkspace /> : <VocabularyWorkspace />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'analyze') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Analyze"
|
||||
subtitle="Query the active graph and test inference rules."
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={analyzeView === 'reasoning'} onClick={() => setAnalyzeView('reasoning')}>
|
||||
Reasoning Playground
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={analyzeView === 'sparql'} onClick={() => setAnalyzeView('sparql')}>
|
||||
SPARQL Querying
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
{analyzeView === 'reasoning' ? <ReasoningWorkspace /> : <SparqlWorkspace />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'decisions') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Decisions"
|
||||
subtitle="Inspect decision chains, causal context, and precedent matches."
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
<DecisionWorkspace />
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'enrich') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Enrich"
|
||||
subtitle="Import, export, and reconcile graph entities."
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={enrichView === 'import'} onClick={() => setEnrichView('import')}>
|
||||
Import and Export
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={enrichView === 'merge'} onClick={() => setEnrichView('merge')}>
|
||||
Diff and Merge
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
{enrichView === 'import' ? <ImportExportWorkspace /> : <DiffMergeWorkspace />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Manage"
|
||||
subtitle="Review provenance, lineage, and governance context."
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
<LineageDiagram />
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<style>{shellStyles}</style>
|
||||
<div className="app-shell">
|
||||
<aside className="app-rail">
|
||||
<div className="brand-pill">SEM</div>
|
||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
className="nav-button"
|
||||
data-active={activeWorkspace === id}
|
||||
onClick={() => setActiveWorkspace(id)}
|
||||
title={hint}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="nav-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
{renderWorkspace()}
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,65 @@
|
||||
/* ── Semantica Explorer — Global CSS Reset ── */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
/* Focus ring for accessibility */
|
||||
:focus-visible {
|
||||
outline: 2px solid rgba(88, 166, 255, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(88, 166, 255, 0.25);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(88, 166, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Monospace font for code elements */
|
||||
code, pre, .mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
/* Selection highlight */
|
||||
::selection {
|
||||
background: rgba(88, 166, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Spin animation for loaders */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<App />,
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export function pairRegistryKey(source: string, target: string): string;
|
||||
export function curveGroupForPair(source: string, target: string): string;
|
||||
@@ -0,0 +1,7 @@
|
||||
export function pairRegistryKey(source, target) {
|
||||
return JSON.stringify([source, target]);
|
||||
}
|
||||
|
||||
export function curveGroupForPair(source, target) {
|
||||
return JSON.stringify([source, target]);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import Graph from "graphology";
|
||||
import type {
|
||||
GraphArrowVisibilityPolicy,
|
||||
GraphBadgeKind,
|
||||
GraphEdgeVariant,
|
||||
GraphLabelVisibilityPolicy,
|
||||
GraphNodeShapeVariant,
|
||||
} from "../workspaces/GraphWorkspace/graphTheme";
|
||||
import { curveGroupForPair, pairRegistryKey } from "./edgePairKeys.js";
|
||||
|
||||
|
||||
export const graph = new Graph({
|
||||
type: "directed",
|
||||
multi: true,
|
||||
allowSelfLoops: false
|
||||
});
|
||||
|
||||
|
||||
|
||||
export interface NodeAttributes {
|
||||
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
color: string;
|
||||
baseColor?: string;
|
||||
mutedColor?: string;
|
||||
glowColor?: string;
|
||||
baseSize?: number;
|
||||
visualPriority?: number;
|
||||
labelPriority?: number;
|
||||
semanticGroup?: string;
|
||||
strokeColor?: string;
|
||||
borderColor?: string;
|
||||
borderSize?: number;
|
||||
nodeVariant?: GraphNodeShapeVariant;
|
||||
nodeShapeVariant?: GraphNodeShapeVariant;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeCount?: number;
|
||||
ringColor?: string;
|
||||
haloColor?: string;
|
||||
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
|
||||
highlighted?: boolean;
|
||||
|
||||
nodeType: string;
|
||||
content: string;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
properties: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface EdgeAttributes {
|
||||
edgeId?: string;
|
||||
familyId?: string;
|
||||
sourceId?: string;
|
||||
targetId?: string;
|
||||
|
||||
size?: number;
|
||||
baseSize?: number;
|
||||
color?: string;
|
||||
baseColor?: string;
|
||||
mutedColor?: string;
|
||||
type?: string;
|
||||
curvature?: number;
|
||||
visualPriority?: number;
|
||||
edgeFamily?: "line" | "parallel" | "bidirectional" | "path";
|
||||
isBidirectional?: boolean;
|
||||
curveGroup?: string | null;
|
||||
edgeVariant?: GraphEdgeVariant;
|
||||
arrowVisibilityPolicy?: GraphArrowVisibilityPolicy;
|
||||
relationshipStrength?: number;
|
||||
isParallelPair?: boolean;
|
||||
parallelIndex?: number;
|
||||
parallelCount?: number;
|
||||
familySize?: number;
|
||||
|
||||
|
||||
edgeType: string;
|
||||
weight: number;
|
||||
properties: Record<string, any>;
|
||||
}
|
||||
|
||||
function normalizeParallelMetadataForPair(source: string, target: string): void {
|
||||
const edgeIds: string[] = [];
|
||||
graph.forEachDirectedEdge(source, target, (edgeId) => {
|
||||
edgeIds.push(String(edgeId));
|
||||
});
|
||||
|
||||
const pairCount = edgeIds.length;
|
||||
const familyCounts = new Map<string, number>();
|
||||
edgeIds.forEach((edgeId) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as EdgeAttributes;
|
||||
const familyId = String(attrs.familyId || edgeId);
|
||||
familyCounts.set(familyId, (familyCounts.get(familyId) ?? 0) + 1);
|
||||
});
|
||||
|
||||
edgeIds
|
||||
.sort((left, right) => {
|
||||
const leftAttrs = graph.getEdgeAttributes(left) as EdgeAttributes;
|
||||
const rightAttrs = graph.getEdgeAttributes(right) as EdgeAttributes;
|
||||
const priorityDelta = Number(rightAttrs.visualPriority ?? 0) - Number(leftAttrs.visualPriority ?? 0);
|
||||
if (priorityDelta !== 0) {
|
||||
return priorityDelta;
|
||||
}
|
||||
const weightDelta = Number(rightAttrs.weight ?? 0) - Number(leftAttrs.weight ?? 0);
|
||||
if (weightDelta !== 0) {
|
||||
return weightDelta;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
})
|
||||
.forEach((edgeId, index) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as EdgeAttributes;
|
||||
const familyId = String(attrs.familyId || edgeId);
|
||||
graph.mergeEdgeAttributes(edgeId, {
|
||||
edgeId,
|
||||
familyId,
|
||||
sourceId: source,
|
||||
targetId: target,
|
||||
isParallelPair: pairCount > 1,
|
||||
parallelIndex: index,
|
||||
parallelCount: pairCount,
|
||||
familySize: familyCounts.get(familyId) ?? 1,
|
||||
curveGroup: curveGroupForPair(source, target),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function batchMergeNodes(
|
||||
nodes: { id: string; attributes: NodeAttributes }[]
|
||||
): void {
|
||||
for (const { id, attributes } of nodes) {
|
||||
graph.mergeNode(id, attributes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function batchMergeEdges(
|
||||
edges: { id: string; familyId?: string; source: string; target: string; attributes: EdgeAttributes }[]
|
||||
): void {
|
||||
const touchedPairs = new Map<string, { source: string; target: string }>();
|
||||
|
||||
for (const { id, familyId, source, target, attributes } of edges) {
|
||||
if (source === target) continue; // skip self-loops; graph was created with allowSelfLoops: false
|
||||
const edgeId = String(attributes.edgeId || id);
|
||||
const resolvedFamilyId = String(attributes.familyId || familyId || edgeId);
|
||||
|
||||
if (graph.hasNode(source) && graph.hasNode(target)) {
|
||||
graph.mergeDirectedEdgeWithKey(edgeId, source, target, {
|
||||
...attributes,
|
||||
edgeId,
|
||||
familyId: resolvedFamilyId,
|
||||
sourceId: source,
|
||||
targetId: target,
|
||||
});
|
||||
touchedPairs.set(pairRegistryKey(source, target), { source, target });
|
||||
}
|
||||
}
|
||||
|
||||
touchedPairs.forEach(({ source, target }) => {
|
||||
normalizeParallelMetadataForPair(source, target);
|
||||
});
|
||||
}
|
||||
|
||||
export function clearGraph(): void {
|
||||
graph.clear();
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
declare module 'vis-timeline/standalone' {
|
||||
export class Timeline {
|
||||
constructor(container: HTMLElement, items: any, options?: any);
|
||||
on(event: string, callback: (properties: any) => void): void;
|
||||
destroy(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'vis-data/standalone' {
|
||||
export class DataSet<T = any> {
|
||||
constructor(data?: T[], options?: any);
|
||||
add(data: T | T[]): void;
|
||||
update(data: T | T[]): void;
|
||||
remove(id: string | number | (string | number)[]): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
.sem-workspace-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 18px;
|
||||
padding: 20px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 24%),
|
||||
linear-gradient(180deg, rgba(8, 17, 29, 0.94), rgba(3, 7, 14, 0.98));
|
||||
}
|
||||
|
||||
.sem-workspace-frame > :last-child {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.sem-workspace-hero {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
padding: 20px 22px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 18, 33, 0.88), rgba(11, 22, 38, 0.72)),
|
||||
radial-gradient(circle at top right, rgba(255, 179, 109, 0.08), transparent 28%);
|
||||
box-shadow: var(--shadow-strong), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.sem-workspace-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sem-workspace-kicker::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--accent-2), var(--warm));
|
||||
box-shadow: 0 0 14px rgba(158, 217, 255, 0.45);
|
||||
}
|
||||
|
||||
.sem-workspace-title {
|
||||
color: var(--text-1);
|
||||
font-size: 28px;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.05em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sem-workspace-subtitle {
|
||||
margin-top: 8px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: 58ch;
|
||||
}
|
||||
|
||||
.sem-workspace-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sem-grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.12fr) minmax(320px, 0.88fr);
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sem-grid-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sem-surface {
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 18, 32, 0.84), rgba(10, 18, 31, 0.72)),
|
||||
radial-gradient(circle at top, rgba(103, 182, 255, 0.05), transparent 34%);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.sem-surface--subtle {
|
||||
background: linear-gradient(180deg, rgba(9, 18, 32, 0.7), rgba(9, 16, 28, 0.52));
|
||||
}
|
||||
|
||||
.sem-surface--accent {
|
||||
border-color: var(--panel-border-strong);
|
||||
}
|
||||
|
||||
.sem-surface-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sem-surface-body--tight {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.sem-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sem-section-eyebrow {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sem-section-title {
|
||||
color: var(--text-1);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sem-section-copy {
|
||||
margin-top: 6px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sem-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(103, 182, 255, 0.16);
|
||||
background: rgba(103, 182, 255, 0.08);
|
||||
color: #9ed9ff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sem-chip--warm {
|
||||
color: #ffce97;
|
||||
border-color: rgba(255, 179, 109, 0.16);
|
||||
background: rgba(255, 179, 109, 0.08);
|
||||
}
|
||||
|
||||
.sem-chip--success {
|
||||
color: #8bf0bf;
|
||||
border-color: rgba(80, 210, 159, 0.16);
|
||||
background: rgba(80, 210, 159, 0.08);
|
||||
}
|
||||
|
||||
.sem-command-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(132, 197, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.sem-command-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sem-segmented {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(132, 197, 255, 0.12);
|
||||
background: rgba(8, 16, 28, 0.58);
|
||||
}
|
||||
|
||||
.sem-button,
|
||||
.sem-button-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.sem-button:hover,
|
||||
.sem-button-secondary:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sem-button {
|
||||
color: white;
|
||||
border: 1px solid rgba(103, 182, 255, 0.2);
|
||||
background: linear-gradient(180deg, rgba(53, 130, 245, 0.3), rgba(25, 88, 185, 0.18));
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sem-button-secondary {
|
||||
color: #d8e8fb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sem-button:disabled,
|
||||
.sem-button-secondary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.sem-segmented button {
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sem-segmented button[data-active="true"] {
|
||||
background: linear-gradient(180deg, rgba(53, 130, 245, 0.34), rgba(25, 88, 185, 0.2));
|
||||
border-color: rgba(132, 197, 255, 0.2);
|
||||
}
|
||||
|
||||
.sem-input,
|
||||
.sem-select,
|
||||
.sem-textarea {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(132, 197, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: var(--text-1);
|
||||
padding: 12px 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.sem-input::placeholder,
|
||||
.sem-textarea::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.sem-textarea {
|
||||
min-height: 160px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.sem-inspector {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sem-empty-state,
|
||||
.sem-loading-state {
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.sem-empty-state-title,
|
||||
.sem-loading-state-title {
|
||||
color: var(--text-1);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
|
||||
.sem-empty-state-copy,
|
||||
.sem-loading-state-copy {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.sem-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sem-list-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(132, 197, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
color: var(--text-1);
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sem-list-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(132, 197, 255, 0.18);
|
||||
background: rgba(103, 182, 255, 0.08);
|
||||
}
|
||||
|
||||
.sem-list-item[data-active="true"] {
|
||||
border-color: rgba(132, 197, 255, 0.22);
|
||||
background: linear-gradient(180deg, rgba(53, 130, 245, 0.18), rgba(25, 88, 185, 0.1));
|
||||
}
|
||||
|
||||
.sem-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.sem-table th {
|
||||
text-align: left;
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.sem-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.sem-grid-two,
|
||||
.sem-grid-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sem-workspace-hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sem-workspace-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
type SurfaceTone = "default" | "subtle" | "accent";
|
||||
type SurfacePadding = "default" | "tight" | "none";
|
||||
type ChipTone = "default" | "warm" | "success";
|
||||
|
||||
function cx(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function WorkspaceFrame({
|
||||
kicker,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
kicker?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-workspace-frame">
|
||||
<header className="sem-workspace-hero">
|
||||
<div>
|
||||
{kicker ? <div className="sem-workspace-kicker">{kicker}</div> : null}
|
||||
<h2 className="sem-workspace-title">{title}</h2>
|
||||
{subtitle ? <p className="sem-workspace-subtitle">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="sem-workspace-actions">{actions}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SurfaceCard({
|
||||
children,
|
||||
tone = "default",
|
||||
padding = "default",
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: SurfaceTone;
|
||||
padding?: SurfacePadding;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div className={cx("sem-surface", tone !== "default" && `sem-surface--${tone}`, className)} style={style}>
|
||||
{padding === "none" ? children : <div className={cx("sem-surface-body", padding === "tight" && "sem-surface-body--tight")}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-section-header">
|
||||
<div>
|
||||
{eyebrow ? <div className="sem-section-eyebrow">{eyebrow}</div> : null}
|
||||
<h3 className="sem-section-title">{title}</h3>
|
||||
{description ? <p className="sem-section-copy">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="sem-command-group">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricChip({
|
||||
children,
|
||||
tone = "default",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: ChipTone;
|
||||
}) {
|
||||
return <span className={cx("sem-chip", tone !== "default" && `sem-chip--${tone}`)}>{children}</span>;
|
||||
}
|
||||
|
||||
export function CommandBar({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-command-bar">
|
||||
<div className="sem-command-group">{left}</div>
|
||||
<div className="sem-command-group">{right}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InspectorPanel({
|
||||
children,
|
||||
open = true,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<SurfaceCard className={cx("sem-inspector", className)} padding="none" style={{ display: open ? "block" : "none" }}>
|
||||
{children}
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-empty-state">
|
||||
{icon}
|
||||
<div className="sem-empty-state-title">{title}</div>
|
||||
<div className="sem-empty-state-copy">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-loading-state">
|
||||
<div className="animate-spin" style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "999px",
|
||||
border: "2px solid rgba(103, 182, 255, 0.18)",
|
||||
borderTopColor: "rgba(158, 217, 255, 0.92)",
|
||||
marginBottom: 12,
|
||||
}} />
|
||||
<div className="sem-loading-state-title">{title}</div>
|
||||
<div className="sem-loading-state-copy">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedControl({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <div className="sem-segmented">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
|
||||
function CausalChainNode({ hop, title, desc }: { hop: number, title: string, desc: string }) {
|
||||
return (
|
||||
<div style={{ marginLeft: hop * 24, paddingLeft: 16, borderLeft: "2px solid rgba(88,166,255,0.3)", position: "relative", marginBottom: 16 }}>
|
||||
<div style={{ position: "absolute", left: -6, top: 4, width: 10, height: 10, borderRadius: "50%", background: "#58a6ff", boxShadow: "0 0 8px #58a6ff" }} />
|
||||
<h4 style={{ margin: "0 0 4px 0", color: "#e6edf3", fontSize: 14 }}>{title}</h4>
|
||||
<p style={{ margin: 0, color: "#8b949e", fontSize: 13 }}>{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DecisionWorkspace() {
|
||||
const [decisions, setDecisions] = useState<any[]>([]);
|
||||
const [selectedDecision, setSelectedDecision] = useState<any | null>(null);
|
||||
const [chain, setChain] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/decisions")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setDecisions(data);
|
||||
if (data.length > 0) handleSelectDecision(data[0]);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleSelectDecision = async (d: any) => {
|
||||
setSelectedDecision(d);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/decisions/${d.decision_id}/chain`);
|
||||
const data = await res.json();
|
||||
setChain(data.chain || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: "100%", background: "#0d1117", overflow: "hidden" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Left Column: Decisions List */}
|
||||
<div className="glass-panel" style={{ width: 320, padding: 24, display: "flex", flexDirection: "column", gap: 16, borderRight: "1px solid rgba(88,166,255,0.2)", borderTop: "none", borderLeft: "none", borderBottom: "none", borderRadius: 0 }}>
|
||||
<h2 style={{ color: "#ffffff", margin: 0, fontSize: 18, borderBottom: "1px solid rgba(255,255,255,0.1)", paddingBottom: 12 }}>
|
||||
Decision Tree
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{decisions.map(d => (
|
||||
<button
|
||||
key={d.decision_id}
|
||||
onClick={() => handleSelectDecision(d)}
|
||||
style={{
|
||||
textAlign: "left", padding: "12px 16px", borderRadius: 8, cursor: "pointer",
|
||||
background: selectedDecision?.decision_id === d.decision_id ? "rgba(88,166,255,0.15)" : "transparent",
|
||||
border: `1px solid ${selectedDecision?.decision_id === d.decision_id ? "#58a6ff" : "rgba(255,255,255,0.1)"}`,
|
||||
color: selectedDecision?.decision_id === d.decision_id ? "#ffffff" : "#c9d1d9",
|
||||
transition: "all 0.2s"
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{d.decision_id}</div>
|
||||
<div style={{ fontSize: 12, color: "#8b949e", marginTop: 4 }}>{d.category || 'Uncategorized'}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Causal Chains */}
|
||||
<div style={{ flex: 1, padding: 32, overflowY: "auto", position: "relative" }}>
|
||||
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse at top right, rgba(88,166,255,0.05), transparent 60%)", pointerEvents: "none" }} />
|
||||
|
||||
{selectedDecision ? (
|
||||
<>
|
||||
<h1 style={{ color: "#ffffff", fontSize: 28, margin: "0 0 8px 0" }}>{selectedDecision.decision_id}</h1>
|
||||
<div style={{ color: "#58a6ff", fontSize: 14, marginBottom: 40 }}>Outcome: {selectedDecision.outcome}</div>
|
||||
|
||||
<div className="glass-panel" style={{ padding: 32, borderRadius: 12 }}>
|
||||
<h3 style={{ color: "#ffffff", margin: "0 0 24px 0", fontSize: 16 }}>Causal Chain</h3>
|
||||
{loading ? (
|
||||
<div style={{ color: "#8b949e" }}>Loading chain...</div>
|
||||
) : chain.length > 0 ? (
|
||||
chain.map((c, i) => (
|
||||
<CausalChainNode key={i} hop={i} title={`${c.relationship} ${c.id}`} desc={c.content || '...'} />
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: "#8b949e" }}>No causal chain found.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", textAlign: "center", marginTop: 100 }}>Select a decision to view details</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
export function DiffMergeWorkspace() {
|
||||
const [primaryId, setPrimaryId] = useState("n-primary-1");
|
||||
const [duplicateId, setDuplicateId] = useState("n-dup-2");
|
||||
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const handleMerge = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/enrich/merge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.merged_into) {
|
||||
setMsg(`Merge success: redirected ${data.edges_updated} edges to ${data.merged_into}`);
|
||||
} else {
|
||||
setMsg("Merge failed...");
|
||||
}
|
||||
} catch (err) {
|
||||
setMsg("Error calling merge endpoint.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 32, gap: 24, boxSizing: "border-box" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 8px 0", color: "#fff" }}>Entity Diff & Merge</h1>
|
||||
<p style={{ margin: 0, color: "#8b949e" }}>Compare suspected duplicate entities and reconcile them.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 24, flex: 1 }}>
|
||||
{/* Primary View */}
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, padding: 24 }}>
|
||||
<h3 style={{ color: "#58a6ff", margin: "0 0 16px 0", borderBottom: "1px solid rgba(88,166,255,0.2)", paddingBottom: 8 }}>Primary Entity</h3>
|
||||
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 13 }}>Primary Node ID</label>
|
||||
<input
|
||||
value={primaryId} onChange={e => setPrimaryId(e.target.value)}
|
||||
style={{ width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", color: "#fff", padding: "8px 12px", borderRadius: 6, marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<div style={{ background: "rgba(0,0,0,0.2)", padding: 16, borderRadius: 6 }}>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginBottom: 4 }}>Name</div>
|
||||
<div style={{ color: "#fff", fontSize: 14 }}>Sample Company Inc.</div>
|
||||
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 16, marginBottom: 4 }}>Founded</div>
|
||||
<div style={{ color: "#fff", fontSize: 14 }}>2004-05-12</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate View */}
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, padding: 24 }}>
|
||||
<h3 style={{ color: "#ff7b72", margin: "0 0 16px 0", borderBottom: "1px solid rgba(255,123,114,0.2)", paddingBottom: 8 }}>Duplicate Entity</h3>
|
||||
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 13 }}>Duplicate Node ID</label>
|
||||
<input
|
||||
value={duplicateId} onChange={e => setDuplicateId(e.target.value)}
|
||||
style={{ width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", color: "#fff", padding: "8px 12px", borderRadius: 6, marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<div style={{ background: "rgba(0,0,0,0.2)", padding: 16, borderRadius: 6 }}>
|
||||
<div style={{ color: "#d2a8ff", fontSize: 12, marginBottom: 4 }}>Name</div>
|
||||
{/* Amber highlight for differing values */}
|
||||
<div style={{ color: "#d29922", fontSize: 14, fontWeight: "bold" }}>Sample Company</div>
|
||||
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 16, marginBottom: 4 }}>Founded</div>
|
||||
<div style={{ color: "#fff", fontSize: 14 }}>2004-05-12</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ color: "#58a6ff" }}>{msg}</div>
|
||||
<button
|
||||
onClick={handleMerge}
|
||||
style={{ background: "#238636", color: "#fff", border: "none", padding: "10px 24px", borderRadius: 6, fontWeight: 600, cursor: "pointer", fontSize: 16 }}
|
||||
>
|
||||
Confirm Merge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
|
||||
export type LinkPrediction = {
|
||||
target: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type PathResponse = {
|
||||
path: string[];
|
||||
edge_ids?: string[];
|
||||
total_weight: number;
|
||||
};
|
||||
|
||||
export interface GraphInspectorPanelProps {
|
||||
nodeId: string;
|
||||
predictions: LinkPrediction[];
|
||||
predictionType: string;
|
||||
onPredictionTypeChange: (value: string) => void;
|
||||
onRunPredictions: () => void;
|
||||
pathTargetId: string;
|
||||
onPathTargetChange: (value: string) => void;
|
||||
onTracePath: () => void;
|
||||
pathResult: PathResponse | null;
|
||||
onDownloadProvenance: (format: "json" | "markdown") => void;
|
||||
}
|
||||
|
||||
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||
|
||||
function sourceAttribution(properties: Record<string, unknown>) {
|
||||
return PROVENANCE_KEYS
|
||||
.filter((key) => key in properties)
|
||||
.map((key) => ({ key, value: properties[key] }));
|
||||
}
|
||||
|
||||
export function GraphInspectorPanel({
|
||||
nodeId,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
onRunPredictions,
|
||||
pathTargetId,
|
||||
onPathTargetChange,
|
||||
onTracePath,
|
||||
pathResult,
|
||||
onDownloadProvenance,
|
||||
}: GraphInspectorPanelProps) {
|
||||
if (!nodeId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: "center" }}>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0 }}>
|
||||
Search for a node or click one in the canvas to inspect its properties.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const attributes = graph.getNodeAttributes(nodeId) as {
|
||||
color?: string;
|
||||
content?: string;
|
||||
label?: string;
|
||||
nodeType?: string;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
const properties = attributes?.properties ?? {};
|
||||
const attribution = sourceAttribution(properties);
|
||||
const accentColor = attributes?.color || "#58a6ff";
|
||||
const propertyEntries = Object.entries(properties).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"x",
|
||||
"y",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
"content",
|
||||
"source",
|
||||
"source_url",
|
||||
"pmid",
|
||||
"pmids",
|
||||
"evidence",
|
||||
"provenance",
|
||||
"confidence",
|
||||
].includes(key),
|
||||
);
|
||||
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
background: accentColor,
|
||||
boxShadow: `0 0 10px ${accentColor}`,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? nodeId)}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6 }}>{nodeId}</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||
{attributes?.valid_from || attributes?.valid_until ? (
|
||||
<span style={subtleChipStyle}>temporal</span>
|
||||
) : null}
|
||||
{attribution.length ? <span style={subtleChipStyle}>{attribution.length} source fields</span> : null}
|
||||
{predictions.length ? <span style={subtleChipStyle}>{predictions.length} candidate links</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(attributes?.valid_from || attributes?.valid_until) && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "#79c0ff",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
|
||||
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Actions</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<button style={{ ...actionButtonStyle, width: "100%", justifyContent: "center" }} onClick={onRunPredictions}>
|
||||
Run Link Prediction
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
|
||||
Provenance JSON
|
||||
</button>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>
|
||||
Provenance MD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
value={predictionType}
|
||||
onChange={(event) => onPredictionTypeChange(event.target.value)}
|
||||
placeholder="Optional candidate type filter, e.g. disease"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Trace Path</div>
|
||||
<input
|
||||
value={pathTargetId}
|
||||
onChange={(event) => onPathTargetChange(event.target.value)}
|
||||
placeholder="Target node ID"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||
{pathResult?.path?.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
|
||||
{pathResult.path.map((step, index) => (
|
||||
<div key={`${step}-${index}`} style={pathStepStyle}>{index + 1}. {step}</div>
|
||||
))}
|
||||
<div style={{ color: "#79c0ff", fontSize: 12, marginTop: 4 }}>
|
||||
total weight: {pathResult.total_weight.toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Choose a target or click a candidate prediction to prepare a path trace.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details className="node-panel-collapse" open={predictions.length > 0}>
|
||||
<summary className="node-panel-summary">Candidate Links</summary>
|
||||
<div className="node-panel-body">
|
||||
{predictions.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{predictions.map((prediction) => (
|
||||
<button
|
||||
key={`${prediction.target}-${prediction.type}`}
|
||||
style={predictionCardStyle}
|
||||
onClick={() => onPathTargetChange(prediction.target)}
|
||||
>
|
||||
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>
|
||||
confidence {prediction.score.toFixed(3)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Run link prediction to surface likely next-hop relationships.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="node-panel-collapse">
|
||||
<summary className="node-panel-summary">Source Attribution</summary>
|
||||
<div className="node-panel-body">
|
||||
{attribution.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{attribution.map(({ key, value }) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No explicit attribution metadata was found on this node.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="node-panel-collapse">
|
||||
<summary className="node-panel-summary">Properties</summary>
|
||||
<div className="node-panel-body">
|
||||
{propertyEntries.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No additional properties are attached to this node.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
background: "rgba(4, 10, 18, 0.5)",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
color: "#edf5ff",
|
||||
borderRadius: 12,
|
||||
padding: "11px 13px",
|
||||
fontSize: 13,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||
};
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||
color: "#fff",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
borderRadius: 12,
|
||||
padding: "9px 12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: `0 8px 22px ${GRAPH_THEME.palette.background.shellGlow}`,
|
||||
};
|
||||
|
||||
const secondaryActionButtonStyle: CSSProperties = {
|
||||
...actionButtonStyle,
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
color: "#c6d4e3",
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const predictionCardStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: 12,
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.12)",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const pathStepStyle: CSSProperties = {
|
||||
color: "#e6edf3",
|
||||
fontSize: 13,
|
||||
padding: "8px 10px",
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const propertyCardStyle: CSSProperties = {
|
||||
background: "rgba(0, 0, 0, 0.2)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const subtleChipStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
color: "#9fb6d2",
|
||||
padding: "4px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
background: "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
borderRadius: 14,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import { GRAPH_LOAD_STAGE_SEQUENCE, createGraphLoadProgress, getGraphLoadStageLabel } from "./graphLoading";
|
||||
import type { GraphLoadProgress } from "./types";
|
||||
|
||||
const LOADING_OVERLAY_CSS = `
|
||||
.graph-stage-loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 220ms ease, transform 220ms ease;
|
||||
}
|
||||
.graph-stage-loader[data-exiting="true"] {
|
||||
opacity: 0;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
.graph-stage-loader-card {
|
||||
width: min(540px, calc(100% - 48px));
|
||||
border-radius: 24px;
|
||||
padding: 20px 20px 18px;
|
||||
border: 1px solid rgba(127, 208, 255, 0.18);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(242, 182, 109, 0.12), transparent 28%),
|
||||
radial-gradient(circle at top left, rgba(127, 208, 255, 0.14), transparent 30%),
|
||||
linear-gradient(145deg, rgba(7, 17, 31, 0.94), rgba(12, 25, 43, 0.82));
|
||||
box-shadow: 0 26px 90px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(18px) saturate(1.08);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.08);
|
||||
}
|
||||
.graph-stage-loader-card[data-live="true"] {
|
||||
width: min(500px, calc(100% - 56px));
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(242, 182, 109, 0.08), transparent 26%),
|
||||
radial-gradient(circle at top left, rgba(127, 208, 255, 0.12), transparent 28%),
|
||||
linear-gradient(145deg, rgba(7, 17, 31, 0.84), rgba(11, 24, 40, 0.72));
|
||||
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
.graph-stage-loader-beacon {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(127, 208, 255, 0.98), rgba(242, 182, 109, 0.94));
|
||||
box-shadow: 0 0 18px rgba(127, 208, 255, 0.4);
|
||||
}
|
||||
.graph-stage-loader-beacon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -7px;
|
||||
border-radius: inherit;
|
||||
border: 1px solid rgba(127, 208, 255, 0.18);
|
||||
animation: graph-loader-beacon 1.9s ease-out infinite;
|
||||
}
|
||||
.graph-stage-loader-track {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.graph-stage-loader-step {
|
||||
border-radius: 999px;
|
||||
padding: 7px 0;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid rgba(127, 208, 255, 0.08);
|
||||
color: rgba(143, 168, 198, 0.72);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.graph-stage-loader-step[data-state="done"] {
|
||||
color: rgba(214, 232, 250, 0.92);
|
||||
border-color: rgba(127, 208, 255, 0.18);
|
||||
background: rgba(89, 155, 220, 0.14);
|
||||
}
|
||||
.graph-stage-loader-step[data-state="active"] {
|
||||
color: #eff7ff;
|
||||
border-color: rgba(242, 182, 109, 0.24);
|
||||
background: linear-gradient(135deg, rgba(49, 108, 172, 0.28), rgba(242, 182, 109, 0.16));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.graph-stage-loader-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(127, 208, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.graph-stage-loader-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgba(74, 163, 255, 0.9), rgba(127, 208, 255, 0.96), rgba(242, 182, 109, 0.92));
|
||||
box-shadow: 0 0 30px rgba(74, 163, 255, 0.28);
|
||||
transition: width 220ms ease;
|
||||
}
|
||||
.graph-stage-loader-bar-indeterminate::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
width: 34%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(74, 163, 255, 0), rgba(127, 208, 255, 0.94), rgba(242, 182, 109, 0.82), rgba(74, 163, 255, 0));
|
||||
box-shadow: 0 0 26px rgba(127, 208, 255, 0.18);
|
||||
animation: graph-loader-sweep 1.5s cubic-bezier(0.22, 1, 0.36, 1) infinite;
|
||||
}
|
||||
@keyframes graph-loader-beacon {
|
||||
0% { transform: scale(0.72); opacity: 0.6; }
|
||||
100% { transform: scale(1.44); opacity: 0; }
|
||||
}
|
||||
@keyframes graph-loader-sweep {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(360%); }
|
||||
}
|
||||
`;
|
||||
|
||||
function formatLayoutSource(source: GraphLoadProgress["layoutSource"]) {
|
||||
switch (source) {
|
||||
case "provided":
|
||||
return "Persisted layout";
|
||||
case "carried":
|
||||
return "Preserved layout";
|
||||
case "runtime":
|
||||
return "Runtime layout";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLayoutState(state: GraphLoadProgress["layoutState"]) {
|
||||
switch (state) {
|
||||
case "bootstrapping":
|
||||
return "Bootstrapping";
|
||||
case "running":
|
||||
return "Settling";
|
||||
case "interactive":
|
||||
return "Interactive";
|
||||
case "stabilized":
|
||||
return "Stable";
|
||||
case "failed":
|
||||
return "Fallback";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const loadingMetricStyle = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(127, 208, 255, 0.12)",
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
color: "#b8cade",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
} satisfies CSSProperties;
|
||||
|
||||
export function GraphLoadingOverlay({
|
||||
progress,
|
||||
visible,
|
||||
showGraphBehind,
|
||||
}: {
|
||||
progress: GraphLoadProgress | null;
|
||||
visible: boolean;
|
||||
showGraphBehind: boolean;
|
||||
}) {
|
||||
const [renderVisible, setRenderVisible] = useState(visible);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const [displayProgress, setDisplayProgress] = useState<GraphLoadProgress>(
|
||||
progress ?? createGraphLoadProgress({
|
||||
phase: "bootstrapping",
|
||||
message: "Preparing graph session",
|
||||
progressKind: "indeterminate",
|
||||
}),
|
||||
);
|
||||
const exitTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress) {
|
||||
setDisplayProgress(progress);
|
||||
}
|
||||
}, [progress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (exitTimerRef.current !== null) {
|
||||
window.clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
setRenderVisible(true);
|
||||
setExiting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExiting(true);
|
||||
exitTimerRef.current = window.setTimeout(() => {
|
||||
setRenderVisible(false);
|
||||
setExiting(false);
|
||||
exitTimerRef.current = null;
|
||||
}, 220);
|
||||
|
||||
return () => {
|
||||
if (exitTimerRef.current !== null) {
|
||||
window.clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [renderVisible, visible]);
|
||||
|
||||
if (!renderVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProgress = progress ?? displayProgress;
|
||||
const isLiveStage = activeProgress.phase === "stabilizing_layout" || activeProgress.showGraphBehind || showGraphBehind;
|
||||
const overlayBackground = isLiveStage
|
||||
? "linear-gradient(180deg, rgba(1,4,9,0.04), rgba(1,4,9,0.18))"
|
||||
: "linear-gradient(180deg, rgba(1,4,9,0.22), rgba(1,4,9,0.5))";
|
||||
const determinateRatio = activeProgress.progressKind === "determinate" && activeProgress.total
|
||||
? Math.max(0.05, Math.min(activeProgress.loaded ?? 0, activeProgress.total) / Math.max(activeProgress.total, 1))
|
||||
: null;
|
||||
const layoutSource = formatLayoutSource(activeProgress.layoutSource);
|
||||
const layoutState = formatLayoutState(activeProgress.layoutState);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="graph-stage-loader"
|
||||
data-exiting={exiting}
|
||||
style={{ background: overlayBackground }}
|
||||
>
|
||||
<style>{LOADING_OVERLAY_CSS}</style>
|
||||
<div className="graph-stage-loader-card" data-live={isLiveStage}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 14, marginBottom: 14 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: "#ffffff", fontSize: 20, fontWeight: 700, letterSpacing: "-0.03em", marginBottom: 6 }}>
|
||||
{activeProgress.title}
|
||||
</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.5 }}>
|
||||
{activeProgress.message}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
|
||||
<div className="graph-stage-loader-beacon" aria-hidden="true" />
|
||||
<div style={{ color: "#d7e9fb", fontSize: 11, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
Stage {activeProgress.stageIndex ?? 1}/{activeProgress.stageCount ?? GRAPH_LOAD_STAGE_SEQUENCE.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="graph-stage-loader-track" style={{ marginBottom: 14 }}>
|
||||
{GRAPH_LOAD_STAGE_SEQUENCE.map((phase, index) => {
|
||||
const current = activeProgress.stageIndex ?? 1;
|
||||
const state = index + 1 < current ? "done" : index + 1 === current ? "active" : "upcoming";
|
||||
return (
|
||||
<div key={phase} className="graph-stage-loader-step" data-state={state}>
|
||||
{getGraphLoadStageLabel(phase)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", marginBottom: 8 }}>
|
||||
<div style={{ color: "#dce9f6", fontSize: 12, fontWeight: 600 }}>
|
||||
{activeProgress.progressKind === "determinate" && activeProgress.total
|
||||
? `${(activeProgress.loaded ?? 0).toLocaleString()} / ${activeProgress.total.toLocaleString()} in current stage`
|
||||
: "Working through this stage"}
|
||||
</div>
|
||||
<div style={{ color: "#90a8c5", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
{activeProgress.progressKind === "determinate" && determinateRatio !== null
|
||||
? `${Math.round(determinateRatio * 100)}%`
|
||||
: "Live"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`graph-stage-loader-bar ${activeProgress.progressKind === "indeterminate" ? "graph-stage-loader-bar-indeterminate" : ""}`}>
|
||||
{activeProgress.progressKind === "determinate" && determinateRatio !== null ? (
|
||||
<span className="graph-stage-loader-bar-fill" style={{ width: `${Math.round(determinateRatio * 100)}%` }} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 14 }}>
|
||||
<span style={loadingMetricStyle}>
|
||||
{activeProgress.nodesLoaded.toLocaleString()}
|
||||
{activeProgress.nodesTotal ? ` / ${activeProgress.nodesTotal.toLocaleString()}` : ""} nodes
|
||||
</span>
|
||||
<span style={loadingMetricStyle}>
|
||||
{activeProgress.edgesLoaded.toLocaleString()}
|
||||
{activeProgress.edgesTotal ? ` / ${activeProgress.edgesTotal.toLocaleString()}` : ""} relationships
|
||||
</span>
|
||||
{layoutSource ? (
|
||||
<span style={{ ...loadingMetricStyle, color: "#a9ddff", borderColor: withAlpha(GRAPH_THEME.palette.accent.hovered, 0.22) }}>
|
||||
{layoutSource}
|
||||
{layoutState ? ` · ${layoutState}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import {
|
||||
chooseColorAccessor,
|
||||
colorForNodeKey,
|
||||
computeDegreeMap,
|
||||
computeEdgeSize,
|
||||
computeNodeSize,
|
||||
computePageRank,
|
||||
deterministicPosition,
|
||||
} from "./graphAnalytics";
|
||||
import { GRAPH_THEME } from "./graphConfig";
|
||||
import type { GraphSceneHandle } from "./scene";
|
||||
import type {
|
||||
GraphDataSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphLayoutSource,
|
||||
GraphLayoutStatus,
|
||||
GraphLoadProgress,
|
||||
GraphPath,
|
||||
GraphSelectedNodeState,
|
||||
GraphStageHandle,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
const STAGE_EFFECTS_STATE: GraphEffectsState = {
|
||||
pathPulseEnabled: false,
|
||||
pathFlowEnabled: false,
|
||||
lensEnabled: false,
|
||||
temporalEmphasisEnabled: false,
|
||||
semanticRegionsEnabled: false,
|
||||
contoursEnabled: false,
|
||||
pathfindingEnabled: false,
|
||||
communitiesEnabled: false,
|
||||
centralityEnabled: false,
|
||||
legendEnabled: false,
|
||||
diagnosticsEnabled: false,
|
||||
lensMode: "neighborhood",
|
||||
effectQuality: "bounded",
|
||||
};
|
||||
|
||||
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
|
||||
|
||||
function yieldToMain(): Promise<void> {
|
||||
if ("scheduler" in window && typeof (window as Window & { scheduler?: { yield?: () => Promise<void> } }).scheduler?.yield === "function") {
|
||||
return (window as Window & { scheduler: { yield: () => Promise<void> } }).scheduler.yield();
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
if (!nodeId || !graph.hasNode(nodeId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attributes = graph.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
return {
|
||||
id: nodeId,
|
||||
label: String(attributes.label || nodeId),
|
||||
content: String(attributes.content || attributes.label || nodeId),
|
||||
nodeType: attributes.nodeType || "entity",
|
||||
color: attributes.color,
|
||||
valid_from: attributes.valid_from ?? null,
|
||||
valid_until: attributes.valid_until ?? null,
|
||||
properties: attributes.properties ?? {},
|
||||
neighborCount: graph.neighbors(nodeId).length,
|
||||
};
|
||||
}
|
||||
|
||||
function hasUsableCoordinate(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
interface GraphRuntimeStageProps {
|
||||
snapshot: GraphDataSnapshot | null | undefined;
|
||||
selectedNodeId: string;
|
||||
activePath: GraphPath;
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
onSelectedNodeStateChange: (state: GraphSelectedNodeState | null) => void;
|
||||
isLayoutRunning: boolean;
|
||||
onLayoutRunningChange: (running: boolean) => void;
|
||||
viewMode: GraphViewMode;
|
||||
temporalTime: Date | null;
|
||||
onActiveNodeCountChange: (count: number | null) => void;
|
||||
onProgressChange: (progress: GraphLoadProgress | null) => void;
|
||||
onLayoutStatusChange: (status: GraphLayoutStatus) => void;
|
||||
onRuntimeReady: () => void;
|
||||
}
|
||||
|
||||
export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageProps>(
|
||||
function GraphRuntimeStage(
|
||||
{
|
||||
snapshot,
|
||||
selectedNodeId,
|
||||
activePath,
|
||||
onNodeSelect,
|
||||
onSelectedNodeStateChange,
|
||||
isLayoutRunning,
|
||||
onLayoutRunningChange,
|
||||
viewMode,
|
||||
temporalTime,
|
||||
onActiveNodeCountChange,
|
||||
onProgressChange,
|
||||
onLayoutStatusChange,
|
||||
onRuntimeReady,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const sceneRef = useRef<GraphSceneHandle>(null);
|
||||
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
||||
const [graphVersion, setGraphVersion] = useState(0);
|
||||
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
|
||||
|
||||
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
fitView: () => sceneRef.current?.fitView(),
|
||||
focusNode: (nodeId: string) => sceneRef.current?.focusNode(nodeId),
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function hydrateSnapshot() {
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
onProgressChange(createGraphLoadProgress({
|
||||
phase: "computing_styling",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: snapshot.summary.nodeCount,
|
||||
nodesTotal: snapshot.summary.nodeCount,
|
||||
edgesLoaded: snapshot.summary.edgeCount,
|
||||
edgesTotal: snapshot.summary.edgeCount,
|
||||
message: "Computing runtime graph styling",
|
||||
showGraphBehind: false,
|
||||
}));
|
||||
|
||||
const degreeByNode = computeDegreeMap(snapshot.nodes, snapshot.edges);
|
||||
const pageRankByNode = computePageRank(snapshot.nodes, snapshot.edges);
|
||||
const nodeIndexById = new Map(snapshot.nodes.map((node, index) => [node.id, index]));
|
||||
const previousPositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
const raw = attributes as Partial<NodeAttributes>;
|
||||
const x = Number(raw.x);
|
||||
const y = Number(raw.y);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||
previousPositions.set(nodeId, { x, y });
|
||||
}
|
||||
});
|
||||
|
||||
let explicitCoordinateCount = 0;
|
||||
let carriedCoordinateCount = 0;
|
||||
const draftAttributes = snapshot.nodes.map((node) => {
|
||||
const previousPosition = previousPositions.get(node.id);
|
||||
const position = hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)
|
||||
? { x: node.x, y: node.y }
|
||||
: previousPosition
|
||||
? previousPosition
|
||||
: deterministicPosition(node.id, nodeIndexById.get(node.id) ?? 0, snapshot.nodes.length);
|
||||
|
||||
if (hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)) {
|
||||
explicitCoordinateCount += 1;
|
||||
} else if (previousPosition) {
|
||||
carriedCoordinateCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
attributes: {
|
||||
label: node.content || node.id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
nodeType: node.type,
|
||||
content: node.content,
|
||||
valid_from: node.valid_from,
|
||||
valid_until: node.valid_until,
|
||||
properties: node.properties,
|
||||
} as NodeAttributes,
|
||||
};
|
||||
});
|
||||
|
||||
const layoutSource: GraphLayoutSource = explicitCoordinateCount > 0
|
||||
? "provided"
|
||||
: carriedCoordinateCount > 0
|
||||
? "carried"
|
||||
: "runtime";
|
||||
const hasCoordinates = explicitCoordinateCount > 0 || carriedCoordinateCount > 0;
|
||||
setRuntimeLayoutSource(layoutSource);
|
||||
|
||||
const colorAccessor = chooseColorAccessor(draftAttributes);
|
||||
await yieldToMain();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onProgressChange(createGraphLoadProgress({
|
||||
phase: "hydrating_scene",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: snapshot.summary.nodeCount,
|
||||
nodesTotal: snapshot.summary.nodeCount,
|
||||
edgesLoaded: snapshot.summary.edgeCount,
|
||||
edgesTotal: snapshot.summary.edgeCount,
|
||||
message: "Hydrating graph scene and renderer",
|
||||
showGraphBehind: false,
|
||||
}));
|
||||
|
||||
const nodesToMerge = draftAttributes.map(({ id, attributes }) => {
|
||||
const colorKey = colorAccessor(id, attributes);
|
||||
const baseColor = colorForNodeKey(colorKey);
|
||||
const dynamicSize = computeNodeSize(id, degreeByNode, pageRankByNode);
|
||||
return {
|
||||
id,
|
||||
attributes: {
|
||||
...attributes,
|
||||
color: baseColor,
|
||||
baseColor,
|
||||
size: dynamicSize,
|
||||
baseSize: dynamicSize,
|
||||
degree: degreeByNode.get(id) ?? 0,
|
||||
pageRank: pageRankByNode.get(id) ?? 0,
|
||||
glowColor: baseColor,
|
||||
borderColor: GRAPH_THEME.nodes.border,
|
||||
borderSize: 1,
|
||||
} as NodeAttributes,
|
||||
};
|
||||
});
|
||||
|
||||
const edgesToMerge = snapshot.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
familyId: edge.familyId,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
attributes: {
|
||||
edgeId: edge.id,
|
||||
familyId: edge.familyId,
|
||||
sourceId: edge.source,
|
||||
targetId: edge.target,
|
||||
weight: edge.weight,
|
||||
edgeType: edge.type,
|
||||
properties: edge.properties,
|
||||
size: computeEdgeSize(edge.weight),
|
||||
baseSize: computeEdgeSize(edge.weight),
|
||||
color: GRAPH_THEME.edges.baseColor,
|
||||
baseColor: GRAPH_THEME.edges.baseColor,
|
||||
} as EdgeAttributes,
|
||||
}));
|
||||
|
||||
clearGraph();
|
||||
batchMergeNodes(nodesToMerge);
|
||||
batchMergeEdges(edgesToMerge);
|
||||
prevActiveIdsRef.current = new Set(snapshot.nodes.map((node) => node.id));
|
||||
|
||||
await yieldToMain();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLayoutStatusChange({
|
||||
state: layoutSource === "runtime" ? "bootstrapping" : "interactive",
|
||||
source: layoutSource,
|
||||
hasCoordinates,
|
||||
layoutReady: layoutSource !== "runtime",
|
||||
displacement: null,
|
||||
elapsedMs: 0,
|
||||
stableSamples: 0,
|
||||
});
|
||||
|
||||
onLayoutRunningChange(layoutSource === "runtime");
|
||||
if (selectedNodeId) {
|
||||
sceneRef.current?.focusNode(selectedNodeId);
|
||||
} else {
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
setGraphVersion((current) => current + 1);
|
||||
if (layoutSource !== "runtime") {
|
||||
onProgressChange(null);
|
||||
} else {
|
||||
onProgressChange(createGraphLoadProgress({
|
||||
phase: "stabilizing_layout",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: snapshot.summary.nodeCount,
|
||||
nodesTotal: snapshot.summary.nodeCount,
|
||||
edgesLoaded: snapshot.summary.edgeCount,
|
||||
edgesTotal: snapshot.summary.edgeCount,
|
||||
message: "Settling runtime layout",
|
||||
showGraphBehind: true,
|
||||
layoutSource,
|
||||
layoutState: "bootstrapping",
|
||||
}));
|
||||
}
|
||||
onRuntimeReady();
|
||||
}
|
||||
|
||||
void hydrateSnapshot();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onLayoutRunningChange, onLayoutStatusChange, onProgressChange, onRuntimeReady, selectedNodeId, snapshot, stageSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNodeId) {
|
||||
onSelectedNodeStateChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectedNodeStateChange(buildSelectedNodeState(selectedNodeId));
|
||||
}, [graphVersion, onSelectedNodeStateChange, selectedNodeId, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot || !temporalTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const applySnapshot = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/temporal/snapshot?at=${encodeURIComponent(temporalTime.toISOString())}`);
|
||||
if (!response.ok || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: { active_node_ids: string[]; active_node_count: number } = await response.json();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextActiveIds = new Set(data.active_node_ids);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = prevActiveIdsRef.current;
|
||||
previous.forEach((id) => {
|
||||
if (!nextActiveIds.has(id) && graph.hasNode(id)) {
|
||||
graph.setNodeAttribute(id, "hidden", true);
|
||||
}
|
||||
});
|
||||
nextActiveIds.forEach((id) => {
|
||||
if (graph.hasNode(id)) {
|
||||
graph.setNodeAttribute(id, "hidden", false);
|
||||
}
|
||||
});
|
||||
|
||||
prevActiveIdsRef.current = nextActiveIds;
|
||||
onActiveNodeCountChange(data.active_node_count);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("[GraphRuntimeStage] temporal snapshot failed", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void applySnapshot();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onActiveNodeCountChange, snapshot, temporalTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = new WebSocket(`${socketProtocol()}//${window.location.host}/ws/graph-updates`);
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === "connection_ack" || message.event !== "graph_mutation") {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventType = message.data?.event_type;
|
||||
const payload = message.data?.payload;
|
||||
if (eventType === "ADD_NODE" && payload?.id) {
|
||||
batchMergeNodes([
|
||||
{
|
||||
id: payload.id,
|
||||
attributes: {
|
||||
label: payload.properties?.content || payload.id,
|
||||
x: Number.isFinite(Number(payload.x ?? payload.properties?.x))
|
||||
? Number(payload.x ?? payload.properties?.x)
|
||||
: deterministicPosition(payload.id, graph.order + 1, Math.max(graph.order + 1, 1)).x,
|
||||
y: Number.isFinite(Number(payload.y ?? payload.properties?.y))
|
||||
? Number(payload.y ?? payload.properties?.y)
|
||||
: deterministicPosition(payload.id, graph.order + 1, Math.max(graph.order + 1, 1)).y,
|
||||
nodeType: payload.type,
|
||||
content: payload.properties?.content || payload.id,
|
||||
valid_from: payload.properties?.valid_from ?? null,
|
||||
valid_until: payload.properties?.valid_until ?? null,
|
||||
properties: payload.properties || {},
|
||||
size: 8,
|
||||
color: colorForNodeKey(`${payload.type || "entity"}:${payload.id}`),
|
||||
baseColor: colorForNodeKey(`${payload.type || "entity"}:${payload.id}`),
|
||||
baseSize: 8,
|
||||
glowColor: colorForNodeKey(`${payload.type || "entity"}:${payload.id}`),
|
||||
borderColor: GRAPH_THEME.nodes.border,
|
||||
borderSize: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (eventType === "ADD_EDGE" && payload?.source_id && payload?.target_id) {
|
||||
batchMergeEdges([
|
||||
{
|
||||
id: String(payload.id),
|
||||
familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
|
||||
source: payload.source_id,
|
||||
target: payload.target_id,
|
||||
attributes: {
|
||||
edgeId: String(payload.id),
|
||||
familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
|
||||
sourceId: payload.source_id,
|
||||
targetId: payload.target_id,
|
||||
weight: Number(payload.weight ?? 1),
|
||||
edgeType: payload.type,
|
||||
properties: payload.properties || {},
|
||||
size: computeEdgeSize(Number(payload.weight ?? 1)),
|
||||
baseSize: computeEdgeSize(Number(payload.weight ?? 1)),
|
||||
color: payload.properties?.inferred ? GRAPH_THEME.edges.pathColor : GRAPH_THEME.edges.baseColor,
|
||||
baseColor: GRAPH_THEME.edges.baseColor,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
setGraphVersion((current) => current + 1);
|
||||
} catch (error) {
|
||||
console.error("[GraphRuntimeStage] websocket update failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SigmaSceneAdapter
|
||||
ref={sceneRef}
|
||||
onNodeSelect={onNodeSelect}
|
||||
selectedEdgeId=""
|
||||
selectedNodeId={selectedNodeId}
|
||||
activePath={activePath}
|
||||
effectsState={STAGE_EFFECTS_STATE}
|
||||
isLayoutRunning={isLayoutRunning}
|
||||
onLayoutRunningChange={onLayoutRunningChange}
|
||||
layoutSource={runtimeLayoutSource}
|
||||
onLayoutStatusChange={onLayoutStatusChange}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,821 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
||||
import { getGraphLoadTitle } from "./graphLoading";
|
||||
import { useGraphData, useReloadGraphData } from "./useGraphData";
|
||||
import type {
|
||||
ApiNode,
|
||||
GraphLayoutStatus,
|
||||
GraphLoadProgress,
|
||||
GraphPath,
|
||||
GraphSelectedNodeState,
|
||||
GraphStageHandle,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
type SearchResult = {
|
||||
node: {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
score: number;
|
||||
};
|
||||
|
||||
type LinkPrediction = {
|
||||
target: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type PathResponse = {
|
||||
path: GraphPath;
|
||||
total_weight: number;
|
||||
};
|
||||
|
||||
type TemporalBounds = {
|
||||
min?: string | null;
|
||||
max?: string | null;
|
||||
};
|
||||
|
||||
const GraphRuntimeStage = lazy(() =>
|
||||
import("./GraphRuntimeStage").then((module) => ({ default: module.GraphRuntimeStage })),
|
||||
);
|
||||
const TimelinePanel = lazy(() =>
|
||||
import("./TimelinePanel").then((module) => ({ default: module.TimelinePanel })),
|
||||
);
|
||||
|
||||
const HUD_CSS = `
|
||||
.palantir-bg {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(103, 182, 255, 0.1), transparent 24%),
|
||||
linear-gradient(180deg, #07111d 0%, #02060e 100%);
|
||||
}
|
||||
.palantir-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(88, 166, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(88, 166, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.78;
|
||||
}
|
||||
.palantir-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 34%, rgba(1, 4, 9, 0.88) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.hud-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.hud-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.hud-scrollbar::-webkit-scrollbar-thumb { background: rgba(88, 166, 255, 0.25); border-radius: 6px; }
|
||||
.graph-shell-top { position: absolute; top: 18px; left: 18px; right: 18px; z-index: 10; display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; pointer-events: none; }
|
||||
.graph-status-card, .graph-command-card {
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgba(132, 197, 255, 0.12);
|
||||
background: linear-gradient(180deg, rgba(7, 16, 29, 0.86), rgba(10, 22, 39, 0.72)), radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 50%);
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.graph-status-card { width: min(420px, 38vw); border-radius: 24px; padding: 16px 18px; }
|
||||
.graph-command-card { width: min(620px, 55vw); border-radius: 24px; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.graph-status-label { display: inline-flex; align-items: center; gap: 8px; color: rgba(160, 191, 223, 0.88); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; }
|
||||
.graph-status-label::before { content: ""; width: 7px; height: 7px; border-radius: 999px; background: linear-gradient(135deg, #8ed3ff, #ffb36a); box-shadow: 0 0 12px rgba(142, 211, 255, 0.5); }
|
||||
.graph-status-title { color: #eef5ff; font-size: 20px; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 6px; }
|
||||
.graph-status-copy { color: #8fa8c6; font-size: 12px; line-height: 1.55; margin-bottom: 14px; max-width: 40ch; }
|
||||
.graph-status-metrics, .graph-command-row, .graph-toggle-cluster, .graph-action-cluster { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.graph-command-row { justify-content: space-between; align-items: center; gap: 10px; }
|
||||
.graph-search-shell { flex: 1; min-width: 260px; display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-radius: 18px; border: 1px solid rgba(132, 197, 255, 0.12); background: rgba(0, 0, 0, 0.18); box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); }
|
||||
.graph-search-shell input { flex: 1; min-width: 0; border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; }
|
||||
.graph-search-shell input:focus { outline: none; }
|
||||
.graph-search-results { position: absolute; top: 120px; right: 18px; width: min(420px, calc(100vw - 132px)); max-height: 320px; overflow-y: auto; padding: 12px; border-radius: 20px; border: 1px solid rgba(132, 197, 255, 0.14); background: linear-gradient(180deg, rgba(8, 18, 33, 0.94), rgba(10, 21, 38, 0.86)); box-shadow: 0 18px 50px rgba(0,0,0,0.34); backdrop-filter: blur(18px); pointer-events: auto; z-index: 11; }
|
||||
.graph-search-results-label { color: #6f89ab; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; }
|
||||
.graph-search-result-card { width: 100%; text-align: left; padding: 12px 14px; border-radius: 16px; border: 1px solid rgba(132, 197, 255, 0.08); background: rgba(255, 255, 255, 0.025); cursor: pointer; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; }
|
||||
.graph-search-result-card:hover { transform: translateY(-1px); border-color: rgba(132, 197, 255, 0.18); background: rgba(103, 182, 255, 0.08); }
|
||||
.graph-inspector { pointer-events: auto; position: absolute; right: 18px; top: 154px; bottom: 108px; width: 380px; overflow-y: auto; transition: transform 0.34s cubic-bezier(0.16,1,0.3,1), opacity 0.22s ease; border-radius: 28px; border: 1px solid rgba(132, 197, 255, 0.14); background: linear-gradient(180deg, rgba(8, 18, 33, 0.9), rgba(6, 12, 22, 0.88)), radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 40%); box-shadow: -18px 0 48px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255,255,255,0.04); backdrop-filter: blur(20px); }
|
||||
.graph-inspector[data-open='false'] { transform: translateX(calc(100% + 24px)); opacity: 0; }
|
||||
@keyframes sem-loader-pulse {
|
||||
0%, 100% { transform: translateY(0) scale(0.92); opacity: 0.55; }
|
||||
50% { transform: translateY(-4px) scale(1.08); opacity: 1; }
|
||||
}
|
||||
@media (max-width: 1220px) {
|
||||
.graph-shell-top { flex-direction: column; align-items: stretch; }
|
||||
.graph-status-card, .graph-command-card { width: auto; }
|
||||
.graph-search-results { top: 202px; right: 18px; left: 18px; width: auto; }
|
||||
}
|
||||
`;
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [delay, value]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
function sourceAttribution(properties: Record<string, unknown>) {
|
||||
const keys = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"];
|
||||
return keys
|
||||
.filter((key) => key in properties)
|
||||
.map((key) => ({ key, value: properties[key] }));
|
||||
}
|
||||
|
||||
function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor = "#58a6ff"): GraphSelectedNodeState {
|
||||
return {
|
||||
id: node.id,
|
||||
label: node.content || node.id,
|
||||
content: node.content || node.id,
|
||||
nodeType: node.type,
|
||||
color: fallbackColor,
|
||||
valid_from: node.valid_from ?? null,
|
||||
valid_until: node.valid_until ?? null,
|
||||
properties: node.properties ?? {},
|
||||
neighborCount,
|
||||
};
|
||||
}
|
||||
|
||||
function TimelineFallback({ min, max }: TemporalBounds) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "90px",
|
||||
borderTop: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
background: "rgba(1, 4, 9, 0.88)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 18px",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span>Temporal scrubber</span>
|
||||
<span>{min || max ? "Preparing timeline runtime..." : "Temporal bounds loading..."}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodePanel({
|
||||
node,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
onRunPredictions,
|
||||
pathTargetId,
|
||||
onPathTargetChange,
|
||||
onTracePath,
|
||||
pathResult,
|
||||
onDownloadProvenance,
|
||||
}: {
|
||||
node: GraphSelectedNodeState | null;
|
||||
predictions: LinkPrediction[];
|
||||
predictionType: string;
|
||||
onPredictionTypeChange: (value: string) => void;
|
||||
onRunPredictions: () => void;
|
||||
pathTargetId: string;
|
||||
onPathTargetChange: (value: string) => void;
|
||||
onTracePath: () => void;
|
||||
pathResult: PathResponse | null;
|
||||
onDownloadProvenance: (format: "json" | "markdown") => void;
|
||||
}) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: "center" }}>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0 }}>
|
||||
Search for a node or click one in the canvas to inspect its properties.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const properties = node.properties ?? {};
|
||||
const attribution = sourceAttribution(properties);
|
||||
const accentColor = node.color || "#58a6ff";
|
||||
const propertyEntries = Object.entries(properties).filter(([key]) => !["x", "y", "valid_from", "valid_until", "content", "source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"].includes(key));
|
||||
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.14)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 800, textTransform: "uppercase", letterSpacing: "0.08em" }}>{node.nodeType || "Entity"}</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 24, lineHeight: 1, fontWeight: 800, letterSpacing: "-0.04em", wordBreak: "break-word" }}>{node.label}</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 8 }}>{node.id}</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||
{node.valid_from || node.valid_until ? <span style={subtleChipStyle}>temporal</span> : null}
|
||||
<span style={subtleChipStyle}>{node.neighborCount} neighbors</span>
|
||||
{attribution.length ? <span style={subtleChipStyle}>{attribution.length} source fields</span> : null}
|
||||
{predictions.length ? <span style={subtleChipStyle}>{predictions.length} candidate links</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Actions</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<button style={{ ...actionButtonStyle, width: "100%", justifyContent: "center" }} onClick={onRunPredictions}>Run Link Prediction</button>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>Provenance JSON</button>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>Provenance MD</button>
|
||||
</div>
|
||||
</div>
|
||||
<input value={predictionType} onChange={(event) => onPredictionTypeChange(event.target.value)} placeholder="Optional candidate type filter, e.g. disease" style={inputStyle} />
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Trace Path</div>
|
||||
<input value={pathTargetId} onChange={(event) => onPathTargetChange(event.target.value)} placeholder="Target node ID" style={inputStyle} />
|
||||
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||
{pathResult?.path?.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
|
||||
{pathResult.path.map((step, index) => (
|
||||
<div key={`${step}-${index}`} style={pathStepStyle}>{index + 1}. {step}</div>
|
||||
))}
|
||||
<div style={{ color: "#79c0ff", fontSize: 12, marginTop: 4 }}>total weight: {pathResult.total_weight.toFixed(3)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Choose a target or click a candidate prediction to prepare a path trace.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details style={collapseStyle} open={predictions.length > 0}>
|
||||
<summary style={summaryStyle}>Candidate Links</summary>
|
||||
<div style={{ padding: "0 14px 14px" }}>
|
||||
{predictions.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{predictions.map((prediction) => (
|
||||
<button key={`${prediction.target}-${prediction.type}`} style={predictionCardStyle} onClick={() => onPathTargetChange(prediction.target)}>
|
||||
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>confidence {prediction.score.toFixed(3)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Run link prediction to surface likely next-hop relationships.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details style={collapseStyle}>
|
||||
<summary style={summaryStyle}>Source Attribution</summary>
|
||||
<div style={{ padding: "0 14px 14px" }}>
|
||||
{attribution.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{attribution.map(({ key, value }) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>{typeof value === "object" ? JSON.stringify(value) : String(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No explicit attribution metadata was found on this node.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details style={collapseStyle}>
|
||||
<summary style={summaryStyle}>Properties</summary>
|
||||
<div style={{ padding: "0 14px 14px" }}>
|
||||
{propertyEntries.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>{typeof value === "object" ? JSON.stringify(value) : String(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No additional properties are attached to this node.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphWorkspaceShell() {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [selectedNodeState, setSelectedNodeState] = useState<GraphSelectedNodeState | null>(null);
|
||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchError, setSearchError] = useState("");
|
||||
const [predictionType, setPredictionType] = useState("");
|
||||
const [predictions, setPredictions] = useState<LinkPrediction[]>([]);
|
||||
const [pathTargetId, setPathTargetId] = useState("");
|
||||
const [pathResult, setPathResult] = useState<PathResponse | null>(null);
|
||||
const [activeNodeCount, setActiveNodeCount] = useState<number | null>(null);
|
||||
const [temporalBounds, setTemporalBounds] = useState<TemporalBounds | null>(null);
|
||||
const [scrubberTime, setScrubberTime] = useState<Date | null>(null);
|
||||
const [loadingProgress, setLoadingProgress] = useState<GraphLoadProgress | null>(null);
|
||||
const [isGraphStageReady, setIsGraphStageReady] = useState(false);
|
||||
const [layoutStatus, setLayoutStatus] = useState<GraphLayoutStatus>({
|
||||
state: "idle",
|
||||
source: "runtime",
|
||||
hasCoordinates: false,
|
||||
layoutReady: false,
|
||||
displacement: null,
|
||||
elapsedMs: 0,
|
||||
stableSamples: 0,
|
||||
});
|
||||
|
||||
const debouncedTime = useDebounce(scrubberTime, 150);
|
||||
const stageRef = useRef<GraphStageHandle>(null);
|
||||
const reload = useReloadGraphData();
|
||||
const { data: snapshot, isLoading, isFetching, isError, error } = useGraphData({ enabled: true, onProgress: setLoadingProgress });
|
||||
|
||||
const handleSelectedNodeStateChange = useCallback((state: GraphSelectedNodeState | null) => {
|
||||
setSelectedNodeState(state);
|
||||
}, []);
|
||||
|
||||
const handleLayoutRunningChange = useCallback((running: boolean) => {
|
||||
setIsLayoutRunning(running);
|
||||
}, []);
|
||||
|
||||
const handleActiveNodeCountChange = useCallback((count: number | null) => {
|
||||
setActiveNodeCount(count);
|
||||
}, []);
|
||||
|
||||
const handleProgressChange = useCallback((progress: GraphLoadProgress | null) => {
|
||||
setLoadingProgress(progress);
|
||||
}, []);
|
||||
|
||||
const handleRuntimeReady = useCallback(() => {
|
||||
setIsGraphStageReady(true);
|
||||
}, []);
|
||||
|
||||
const handleLayoutStatusChange = useCallback((status: GraphLayoutStatus) => {
|
||||
setLayoutStatus(status);
|
||||
if (status.layoutReady) {
|
||||
setLoadingProgress(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot) {
|
||||
setIsGraphStageReady(false);
|
||||
setActiveNodeCount(null);
|
||||
setLayoutStatus({
|
||||
state: snapshot.summary.layoutReady ? "interactive" : "idle",
|
||||
source: snapshot.summary.layoutSource ?? "runtime",
|
||||
hasCoordinates: snapshot.summary.hasCoordinates ?? false,
|
||||
layoutReady: snapshot.summary.layoutReady ?? false,
|
||||
displacement: null,
|
||||
elapsedMs: 0,
|
||||
stableSamples: 0,
|
||||
});
|
||||
}
|
||||
}, [snapshot?.fetchedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadBounds = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/temporal/bounds");
|
||||
if (!response.ok || cancelled) return;
|
||||
const data: TemporalBounds = await response.json();
|
||||
if (!cancelled) setTemporalBounds(data);
|
||||
} catch {
|
||||
if (!cancelled) setTemporalBounds(null);
|
||||
}
|
||||
};
|
||||
void loadBounds();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [snapshot?.summary.nodeCount, snapshot?.summary.edgeCount]);
|
||||
|
||||
const neighborCountMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
if (!snapshot) return map;
|
||||
for (const node of snapshot.nodes) map.set(node.id, 0);
|
||||
for (const edge of snapshot.edges) {
|
||||
map.set(edge.source, (map.get(edge.source) ?? 0) + 1);
|
||||
map.set(edge.target, (map.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}, [snapshot]);
|
||||
|
||||
const visibleSelectedNode = useMemo(() => {
|
||||
if (!selectedNodeId) return null;
|
||||
if (selectedNodeState?.id === selectedNodeId) return selectedNodeState;
|
||||
const snapshotNode = snapshot?.nodes.find((candidate) => candidate.id === selectedNodeId);
|
||||
if (snapshotNode) return toSelectedNodeState(snapshotNode, neighborCountMap.get(snapshotNode.id) ?? 0);
|
||||
const searchNode = searchResults.find((candidate) => candidate.node.id === selectedNodeId)?.node;
|
||||
return searchNode
|
||||
? {
|
||||
id: searchNode.id,
|
||||
label: searchNode.content || searchNode.id,
|
||||
content: searchNode.content || searchNode.id,
|
||||
nodeType: searchNode.type,
|
||||
color: "#58a6ff",
|
||||
valid_from: null,
|
||||
valid_until: null,
|
||||
properties: searchNode.properties ?? {},
|
||||
neighborCount: 0,
|
||||
}
|
||||
: null;
|
||||
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
setSelectedNodeId(nodeId);
|
||||
setPathResult(null);
|
||||
|
||||
if (!nodeId) {
|
||||
setSelectedNodeState(null);
|
||||
setPredictions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchResults([]);
|
||||
setIsLayoutRunning(false);
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchError("");
|
||||
try {
|
||||
const response = await fetch("/api/graph/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: searchQuery, limit: 8 }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSearchResults(data.results || []);
|
||||
if (data.results?.length) {
|
||||
focusNode(data.results[0].node.id);
|
||||
}
|
||||
} catch (searchFetchError) {
|
||||
setSearchError(searchFetchError instanceof Error ? searchFetchError.message : "Search failed");
|
||||
}
|
||||
}, [focusNode, searchQuery]);
|
||||
|
||||
const handleRunPredictions = useCallback(async () => {
|
||||
if (!selectedNodeId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/enrich/links", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
node_id: selectedNodeId,
|
||||
top_n: 6,
|
||||
candidate_type: predictionType || undefined,
|
||||
min_score: 0,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Link prediction failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setPredictions(data.predictions || []);
|
||||
} catch (predictionError) {
|
||||
console.error("[GraphWorkspaceShell] prediction failed", predictionError);
|
||||
setPredictions([]);
|
||||
}
|
||||
}, [predictionType, selectedNodeId]);
|
||||
|
||||
const handleTracePath = useCallback(async () => {
|
||||
if (!selectedNodeId || !pathTargetId.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data: PathResponse = await response.json();
|
||||
setPathResult(data);
|
||||
if (data.path?.length) {
|
||||
const lastStep = data.path[data.path.length - 1];
|
||||
stageRef.current?.focusNode(lastStep);
|
||||
}
|
||||
} catch (pathError) {
|
||||
console.error("[GraphWorkspaceShell] path trace failed", pathError);
|
||||
setPathResult(null);
|
||||
}
|
||||
}, [pathTargetId, selectedNodeId]);
|
||||
|
||||
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
|
||||
if (!selectedNodeId) return;
|
||||
|
||||
const suffix = format === "markdown" ? "markdown" : "json";
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(selectedNodeId)}&format=${suffix}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provenance report failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(anchor);
|
||||
}, [selectedNodeId]);
|
||||
|
||||
const searchSummary = useMemo(() => {
|
||||
if (!searchResults.length) return null;
|
||||
return `${searchResults.length} search result${searchResults.length === 1 ? "" : "s"}`;
|
||||
}, [searchResults.length]);
|
||||
|
||||
const focusedSummary = useMemo(() => {
|
||||
if (!visibleSelectedNode) return null;
|
||||
if (viewMode === "focused") {
|
||||
const visibleNeighbors = Math.min(visibleSelectedNode.neighborCount, 16);
|
||||
return `${visibleNeighbors + 1} nodes in focused view`;
|
||||
}
|
||||
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
|
||||
}, [viewMode, visibleSelectedNode]);
|
||||
|
||||
const showLoadingOverlay =
|
||||
isLoading
|
||||
|| isFetching
|
||||
|| !isGraphStageReady
|
||||
|| (layoutStatus.source === "runtime" && !layoutStatus.layoutReady && !selectedNodeId && viewMode === "full");
|
||||
|
||||
const layoutStatusLabel = useMemo(() => {
|
||||
if (layoutStatus.source === "provided" && layoutStatus.layoutReady) return "Persisted layout";
|
||||
if (layoutStatus.source === "carried" && layoutStatus.layoutReady) return "Preserved layout";
|
||||
if (layoutStatus.state === "bootstrapping") return "Bootstrapping layout";
|
||||
if (layoutStatus.state === "running") return "Stabilizing layout";
|
||||
if (layoutStatus.state === "failed") return "Layout timeout fallback";
|
||||
return null;
|
||||
}, [layoutStatus]);
|
||||
|
||||
return (
|
||||
<div className="palantir-bg" style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
<style>{HUD_CSS}</style>
|
||||
<div className="palantir-grid" />
|
||||
<div className="palantir-vignette" />
|
||||
|
||||
<div style={{ flex: 1, position: "relative", zIndex: 3, minHeight: 0 }}>
|
||||
<Suspense fallback={null}>
|
||||
<GraphRuntimeStage
|
||||
ref={stageRef}
|
||||
snapshot={snapshot}
|
||||
selectedNodeId={selectedNodeId}
|
||||
activePath={pathResult?.path ?? []}
|
||||
onNodeSelect={focusNode}
|
||||
onSelectedNodeStateChange={handleSelectedNodeStateChange}
|
||||
isLayoutRunning={isLayoutRunning}
|
||||
onLayoutRunningChange={handleLayoutRunningChange}
|
||||
viewMode={viewMode}
|
||||
temporalTime={debouncedTime}
|
||||
onActiveNodeCountChange={handleActiveNodeCountChange}
|
||||
onProgressChange={handleProgressChange}
|
||||
onLayoutStatusChange={handleLayoutStatusChange}
|
||||
onRuntimeReady={handleRuntimeReady}
|
||||
/>
|
||||
</Suspense>
|
||||
<GraphLoadingOverlay
|
||||
progress={loadingProgress}
|
||||
visible={showLoadingOverlay}
|
||||
showGraphBehind={Boolean(loadingProgress?.showGraphBehind || isGraphStageReady)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<TimelineFallback min={temporalBounds?.min ?? null} max={temporalBounds?.max ?? null} />}>
|
||||
<TimelinePanel
|
||||
onTimeChange={setScrubberTime}
|
||||
minDate={temporalBounds?.min ?? undefined}
|
||||
maxDate={temporalBounds?.max ?? undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<div style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 10 }}>
|
||||
<div className="graph-shell-top">
|
||||
<section className="graph-status-card">
|
||||
<div className="graph-status-label">Graph Studio</div>
|
||||
<div className="graph-status-title">{visibleSelectedNode ? visibleSelectedNode.label : "Knowledge Explorer"}</div>
|
||||
<div className="graph-status-metrics">
|
||||
{showLoadingOverlay && loadingProgress ? <span style={{ ...metricPillStyle, color: "#a9ddff" }}>{getGraphLoadTitle(loadingProgress.phase)}</span> : null}
|
||||
{layoutStatusLabel ? <span style={{ ...metricPillStyle, color: "#a9ddff" }}>{layoutStatusLabel}</span> : null}
|
||||
{snapshot ? <span style={metricPillStyle}>{snapshot.summary.nodeCount.toLocaleString()} nodes · {snapshot.summary.edgeCount.toLocaleString()} edges</span> : null}
|
||||
{activeNodeCount !== null ? <span style={{ ...metricPillStyle, color: "#4fd49c", borderColor: "rgba(79, 212, 156, 0.22)" }}>{activeNodeCount.toLocaleString()} active</span> : null}
|
||||
{searchSummary ? <span style={metricPillStyle}>{searchSummary}</span> : null}
|
||||
{focusedSummary ? <span style={{ ...metricPillStyle, color: "#f2b66d", borderColor: "rgba(242, 182, 109, 0.24)" }}>{focusedSummary}</span> : null}
|
||||
{isError ? <span style={{ ...metricPillStyle, color: "#ff8f85", borderColor: "rgba(255, 123, 114, 0.22)" }}>{(error as Error).message}</span> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="graph-command-card">
|
||||
<div className="graph-command-row">
|
||||
<div className="graph-toggle-cluster">
|
||||
{selectedNodeId ? (
|
||||
<>
|
||||
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
||||
<button onClick={() => setViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="graph-action-cluster">
|
||||
<button onClick={() => setIsLayoutRunning((value) => !value)} style={secondaryActionButtonStyle} disabled={isLoading || isFetching}>
|
||||
{isLayoutRunning ? "Pause Layout" : "Run Layout"}
|
||||
</button>
|
||||
<button onClick={() => { setIsGraphStageReady(false); reload(); }} style={secondaryActionButtonStyle} disabled={isLoading || isFetching}>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="graph-command-row">
|
||||
<div className="graph-search-shell">
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
void handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder="Search a node, e.g. Metformin"
|
||||
style={{ ...inputStyle, minWidth: 260 }}
|
||||
disabled={showLoadingOverlay && !selectedNodeId}
|
||||
/>
|
||||
<button onClick={() => void handleSearch()} style={actionButtonStyle} disabled={showLoadingOverlay && !selectedNodeId}>Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{searchError ? <div style={{ position: "absolute", top: 144, right: 34, color: "#ff7b72", fontSize: 12, pointerEvents: "auto" }}>{searchError}</div> : null}
|
||||
{searchResults.length ? (
|
||||
<div className="graph-search-results hud-scrollbar">
|
||||
<div className="graph-search-results-label">Search Results</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{searchResults.map((result) => (
|
||||
<button key={result.node.id} className="graph-search-result-card" onClick={() => focusNode(result.node.id)}>
|
||||
<div style={{ color: "#fff", fontWeight: 700 }}>{result.node.content || result.node.id}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{result.node.type}</div>
|
||||
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>score {result.score.toFixed(3)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="graph-inspector hud-scrollbar" data-open={selectedNodeId ? "true" : "false"}>
|
||||
<NodePanel
|
||||
node={visibleSelectedNode}
|
||||
predictions={predictions}
|
||||
predictionType={predictionType}
|
||||
onPredictionTypeChange={setPredictionType}
|
||||
onRunPredictions={() => void handleRunPredictions()}
|
||||
pathTargetId={pathTargetId}
|
||||
onPathTargetChange={setPathTargetId}
|
||||
onTracePath={() => void handleTracePath()}
|
||||
pathResult={pathResult}
|
||||
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metricPillStyle: CSSProperties = {
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
color: "#8ed3ff",
|
||||
padding: "6px 11px",
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
border: "1px solid rgba(88, 166, 255, 0.14)",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
background: "linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0.01))",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
borderRadius: 16,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
background: "rgba(0, 0, 0, 0.24)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.14)",
|
||||
color: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "10px 12px",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
background: "linear-gradient(180deg, rgba(53, 130, 245, 0.28), rgba(25, 88, 185, 0.18))",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
borderRadius: 12,
|
||||
padding: "10px 13px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const secondaryActionButtonStyle: CSSProperties = {
|
||||
...actionButtonStyle,
|
||||
background: "rgba(255, 255, 255, 0.035)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
color: "#d6e5f8",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const predictionCardStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: 12,
|
||||
background: "rgba(88, 166, 255, 0.06)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.1)",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const pathStepStyle: CSSProperties = {
|
||||
color: "#e6edf3",
|
||||
fontSize: 13,
|
||||
padding: "8px 10px",
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const propertyCardStyle: CSSProperties = {
|
||||
background: "rgba(0, 0, 0, 0.18)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const subtleChipStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.035)",
|
||||
color: "#9fb6d2",
|
||||
padding: "5px 9px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
};
|
||||
|
||||
const collapseStyle: CSSProperties = {
|
||||
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 14,
|
||||
background: "rgba(0, 0, 0, 0.14)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
listStyle: "none",
|
||||
padding: "12px 14px",
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import { GraphCanvas, type GraphCanvasHandle } from "./GraphCanvas";
|
||||
import type { GraphSceneAdapter, GraphSceneHandle, GraphSceneProps, GraphSceneRuntime } from "./scene";
|
||||
|
||||
export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
|
||||
function SigmaSceneAdapter(
|
||||
{
|
||||
onNodeSelect,
|
||||
onEdgeSelect,
|
||||
onInteractionStateChange,
|
||||
onCameraStateChange,
|
||||
onDiagnosticsChange,
|
||||
onAnalyticsChange,
|
||||
onRuntimeChange,
|
||||
onLayoutRunningChange,
|
||||
...sceneProps
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const canvasRef = useRef<GraphCanvasHandle>(null);
|
||||
const runtimeRef = useRef<GraphSceneRuntime | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
fitView: () => canvasRef.current?.fitView(),
|
||||
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
|
||||
getRuntime: () => runtimeRef.current,
|
||||
setLayoutRunning: onLayoutRunningChange
|
||||
? (running: boolean) => {
|
||||
onLayoutRunningChange(running);
|
||||
}
|
||||
: undefined,
|
||||
}), [onLayoutRunningChange]);
|
||||
|
||||
return (
|
||||
<GraphCanvas
|
||||
ref={canvasRef}
|
||||
onNodeClick={onNodeSelect ?? (() => {})}
|
||||
onEdgeClick={onEdgeSelect}
|
||||
onInteractionStateChange={onInteractionStateChange}
|
||||
onCameraStateChange={onCameraStateChange}
|
||||
onDiagnosticsChange={onDiagnosticsChange}
|
||||
onAnalyticsChange={onAnalyticsChange}
|
||||
onSceneRuntimeChange={(runtime) => {
|
||||
runtimeRef.current = runtime;
|
||||
onRuntimeChange?.(runtime);
|
||||
}}
|
||||
onLayoutRunningChange={onLayoutRunningChange}
|
||||
{...sceneProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
) as GraphSceneAdapter;
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { DataSet } from "vis-data";
|
||||
import { Timeline } from "vis-timeline";
|
||||
import type { TimelineOptions } from "vis-timeline";
|
||||
import "vis-timeline/styles/vis-timeline-graph2d.css";
|
||||
|
||||
export interface TimelinePanelProps {
|
||||
onTimeChange: (time: Date) => void;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_DATE = new Date("1970-01-01T00:00:00Z");
|
||||
const DEFAULT_MAX_DATE = new Date("2030-01-01T00:00:00Z");
|
||||
const PLAYHEAD_ID = "playhead";
|
||||
const PLAY_INTERVAL_MS = 500;
|
||||
const PLAY_STEP_MONTHS = 6;
|
||||
|
||||
const VIS_OVERRIDE_CSS = `
|
||||
.sem-timeline-wrap .vis-timeline { border: none !important; background: transparent !important; overflow: visible !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-background, .sem-timeline-wrap .vis-panel.vis-center { background: transparent !important; }
|
||||
.sem-timeline-wrap .vis-panel { border-color: rgba(88, 166, 255, 0.15) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text {
|
||||
color: #8b949e !important;
|
||||
font-size: 11px !important;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text.vis-major {
|
||||
color: #c9d1d9 !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: rgba(88, 166, 255, 0.07) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: rgba(88, 166, 255, 0.18) !important; }
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} {
|
||||
background: rgba(88, 166, 255, 0.15) !important;
|
||||
width: 2px !important;
|
||||
cursor: ew-resize !important;
|
||||
z-index: 5 !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} > .vis-custom-time-marker {
|
||||
background: #58a6ff !important;
|
||||
color: #0d1117 !important;
|
||||
font-size: 10px !important;
|
||||
font-weight: 700 !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 1px 5px !important;
|
||||
white-space: nowrap !important;
|
||||
box-shadow: 0 0 8px rgba(88, 166, 255, 0.7) !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-current-time { display: none !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
|
||||
`;
|
||||
|
||||
function safeDate(value: string | undefined, fallback: Date): Date {
|
||||
if (!value) return fallback;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function formatPlayheadLabel(value: Date): string {
|
||||
return `${value.getFullYear()}/${String(value.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<Timeline | null>(null);
|
||||
const playheadRef = useRef<Date>(DEFAULT_MIN_DATE);
|
||||
const playIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [displayDate, setDisplayDate] = useState(formatPlayheadLabel(DEFAULT_MIN_DATE));
|
||||
|
||||
const minBound = useMemo(() => safeDate(minDate, DEFAULT_MIN_DATE), [minDate]);
|
||||
const maxBound = useMemo(() => safeDate(maxDate, DEFAULT_MAX_DATE), [maxDate]);
|
||||
const defaultTime = useMemo(() => new Date(Math.round((minBound.getTime() + maxBound.getTime()) / 2)), [maxBound, minBound]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
const items = new DataSet([]);
|
||||
const options: TimelineOptions = {
|
||||
height: "100%",
|
||||
min: minBound,
|
||||
max: maxBound,
|
||||
start: minBound,
|
||||
end: maxBound,
|
||||
showCurrentTime: false,
|
||||
zoomable: true,
|
||||
moveable: true,
|
||||
zoomMin: 1000 * 60 * 60 * 24 * 365,
|
||||
zoomMax: 1000 * 60 * 60 * 24 * 365 * 80,
|
||||
showMajorLabels: true,
|
||||
showMinorLabels: true,
|
||||
timeAxis: { scale: "year", step: 5 },
|
||||
format: { minorLabels: { year: "YYYY" }, majorLabels: { year: "YYYY" } },
|
||||
orientation: { axis: "bottom" },
|
||||
margin: { item: 0, axis: 0 },
|
||||
selectable: false,
|
||||
stack: false,
|
||||
} as TimelineOptions;
|
||||
|
||||
const nextTimeline = new Timeline(containerRef.current, items, options);
|
||||
timelineRef.current = nextTimeline;
|
||||
playheadRef.current = defaultTime;
|
||||
nextTimeline.addCustomTime(defaultTime, PLAYHEAD_ID);
|
||||
nextTimeline.on("timechange", (props: { id: string; time: Date }) => {
|
||||
if (props.id !== PLAYHEAD_ID) return;
|
||||
playheadRef.current = props.time;
|
||||
nextTimeline.setCustomTime(props.time, PLAYHEAD_ID);
|
||||
onTimeChange(props.time);
|
||||
setDisplayDate(formatPlayheadLabel(props.time));
|
||||
});
|
||||
onTimeChange(defaultTime);
|
||||
setDisplayDate(formatPlayheadLabel(defaultTime));
|
||||
return () => {
|
||||
nextTimeline.destroy();
|
||||
timelineRef.current = null;
|
||||
};
|
||||
}
|
||||
|
||||
timeline.setOptions({ min: minBound, max: maxBound, start: minBound, end: maxBound });
|
||||
playheadRef.current = defaultTime;
|
||||
timeline.setCustomTime(defaultTime, PLAYHEAD_ID);
|
||||
onTimeChange(defaultTime);
|
||||
setDisplayDate(formatPlayheadLabel(defaultTime));
|
||||
}, [defaultTime, maxBound, minBound, onTimeChange]);
|
||||
|
||||
const startPlay = useCallback(() => {
|
||||
if (playIntervalRef.current) return;
|
||||
playIntervalRef.current = setInterval(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) return;
|
||||
const next = new Date(playheadRef.current);
|
||||
next.setMonth(next.getMonth() + PLAY_STEP_MONTHS);
|
||||
if (next >= maxBound) {
|
||||
next.setTime(minBound.getTime());
|
||||
}
|
||||
playheadRef.current = next;
|
||||
timeline.setCustomTime(next, PLAYHEAD_ID);
|
||||
onTimeChange(next);
|
||||
setDisplayDate(formatPlayheadLabel(next));
|
||||
}, PLAY_INTERVAL_MS);
|
||||
}, [maxBound, minBound, onTimeChange]);
|
||||
|
||||
const stopPlay = useCallback(() => {
|
||||
if (playIntervalRef.current) {
|
||||
clearInterval(playIntervalRef.current);
|
||||
playIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
setIsPlaying((previous) => {
|
||||
if (previous) {
|
||||
stopPlay();
|
||||
return false;
|
||||
}
|
||||
startPlay();
|
||||
return true;
|
||||
});
|
||||
}, [startPlay, stopPlay]);
|
||||
|
||||
useEffect(() => () => stopPlay(), [stopPlay]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: "1px solid rgba(88, 166, 255, 0.2)", background: "rgba(1, 4, 9, 0.88)", backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
|
||||
<style>{VIS_OVERRIDE_CSS}</style>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: "1px solid rgba(88, 166, 255, 0.15)", minWidth: 80, flexShrink: 0 }}>
|
||||
<button
|
||||
id="temporal-play-btn"
|
||||
onClick={togglePlay}
|
||||
title={isPlaying ? "Pause Evolution" : "Play Evolution"}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? "#58a6ff" : "rgba(88, 166, 255, 0.35)"}`, background: isPlaying ? "rgba(88, 166, 255, 0.2)" : "rgba(88, 166, 255, 0.06)", color: "#58a6ff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(88, 166, 255, 0.4)" : "none" }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
||||
)}
|
||||
</button>
|
||||
<span style={{ fontSize: 10, color: isPlaying ? "#58a6ff" : "#8b949e", fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
|
||||
{displayDate}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: "rgba(88, 166, 255, 0.55)", textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
||||
Temporal Scrubber · {minBound.getFullYear()}-{maxBound.getFullYear()}
|
||||
</div>
|
||||
|
||||
<div className="sem-timeline-wrap" style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export const clickSelectionBehavior: GraphBehavior = {
|
||||
id: "click-selection",
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
onNodeClick: (context, nodeId) => {
|
||||
context.setHoveredNodeId(nodeId);
|
||||
context.onEdgeSelectionChange("");
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
},
|
||||
onEdgeClick: (context, edgeId) => {
|
||||
context.setHoveredNodeId(null);
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
},
|
||||
onStageClick: (context) => {
|
||||
context.setHoveredNodeId(null);
|
||||
context.onEdgeSelectionChange("");
|
||||
context.onNodeSelectionChange("");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export const fitViewBehavior: GraphBehavior = {
|
||||
id: "fit-view",
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
performAction: (context, action) => {
|
||||
if (action.type !== "fitView") {
|
||||
return false;
|
||||
}
|
||||
|
||||
context.fitCurrentView();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export const focusCameraBehavior: GraphBehavior = {
|
||||
id: "focus-camera",
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
performAction: (context, action) => {
|
||||
if (action.type !== "focusNode") {
|
||||
return false;
|
||||
}
|
||||
|
||||
context.focusNodeInView(action.nodeId);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export const hoverActivationBehavior: GraphBehavior = {
|
||||
id: "hover-activation",
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
onNodeEnter: (context, nodeId) => {
|
||||
context.setHoveredNodeId(nodeId);
|
||||
},
|
||||
onNodeLeave: (context, nodeId) => {
|
||||
if (context.getInteractionState().hoveredNodeId === nodeId) {
|
||||
context.setHoveredNodeId(null);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createPathHighlightBehavior(): GraphBehavior {
|
||||
let lastPathSignature = "";
|
||||
|
||||
return {
|
||||
id: "path-highlight",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastPathSignature = "";
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextPathSignature = interactionState.activePath.join("::");
|
||||
if (nextPathSignature === lastPathSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPathSignature = nextPathSignature;
|
||||
context.sigma.refresh();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createSearchFocusBehavior(): GraphBehavior {
|
||||
let lastFocusedNodeId = "";
|
||||
|
||||
return {
|
||||
id: "search-focus",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastFocusedNodeId = "";
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) {
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
return;
|
||||
}
|
||||
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type Graph from "graphology";
|
||||
import type Sigma from "sigma";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../../store/graphStore";
|
||||
import type { GraphCameraState, GraphInteractionState } from "../types";
|
||||
|
||||
export type GraphBehaviorActionRequest =
|
||||
| { type: "fitView" }
|
||||
| { type: "focusNode"; nodeId: string };
|
||||
|
||||
export interface GraphBehaviorContext {
|
||||
sigma: Sigma;
|
||||
graph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
getInteractionState: () => GraphInteractionState;
|
||||
setHoveredNodeId: (nodeId: string | null) => void;
|
||||
onNodeSelectionChange: (nodeId: string) => void;
|
||||
onEdgeSelectionChange: (edgeId: string) => void;
|
||||
focusNodeInView: (nodeId: string) => void;
|
||||
fitCurrentView: () => void;
|
||||
dispatchAction: (action: GraphBehaviorActionRequest) => void;
|
||||
}
|
||||
|
||||
export interface GraphBehavior {
|
||||
id: string;
|
||||
attach: (context: GraphBehaviorContext) => void;
|
||||
detach: (context: GraphBehaviorContext) => void;
|
||||
onNodeEnter?: (context: GraphBehaviorContext, nodeId: string) => void;
|
||||
onNodeLeave?: (context: GraphBehaviorContext, nodeId: string) => void;
|
||||
onNodeClick?: (context: GraphBehaviorContext, nodeId: string) => void;
|
||||
onEdgeClick?: (context: GraphBehaviorContext, edgeId: string) => void;
|
||||
onStageClick?: (context: GraphBehaviorContext) => void;
|
||||
onCameraChange?: (context: GraphBehaviorContext, cameraState: GraphCameraState) => void;
|
||||
onStateChange?: (context: GraphBehaviorContext, interactionState: GraphInteractionState) => void;
|
||||
apply?: (context: GraphBehaviorContext, interactionState: GraphInteractionState) => void;
|
||||
performAction?: (context: GraphBehaviorContext, action: GraphBehaviorActionRequest) => boolean;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
let lastViewMode: "focused" | "full" | null = null;
|
||||
|
||||
return {
|
||||
id: "view-mode-switch",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastViewMode = null;
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
if (interactionState.viewMode === lastViewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastViewMode = interactionState.viewMode;
|
||||
|
||||
if (interactionState.focusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
|
||||
return;
|
||||
}
|
||||
|
||||
context.dispatchAction({ type: "fitView" });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import type Graph from "graphology";
|
||||
import louvain from "graphology-communities-louvain";
|
||||
import { dijkstra } from "graphology-shortest-path";
|
||||
import betweennessCentrality from "graphology-metrics/centrality/betweenness";
|
||||
import {
|
||||
degreeCentrality,
|
||||
inDegreeCentrality,
|
||||
outDegreeCentrality,
|
||||
} from "graphology-metrics/centrality/degree";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, hashString } from "./graphTheme";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDataSnapshot,
|
||||
GraphCentralityNodeSummary,
|
||||
GraphCommunitySummary,
|
||||
GraphInteractionState,
|
||||
GraphSemanticRegionSummary,
|
||||
} from "./types";
|
||||
|
||||
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
|
||||
type GraphCentralityRecord = {
|
||||
degree: number;
|
||||
inDegree: number;
|
||||
outDegree: number;
|
||||
betweenness: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type GraphAnalyticsBase = {
|
||||
communitiesByNode: Map<string, number>;
|
||||
communityCount: number;
|
||||
modularity: number | null;
|
||||
centralityByNode: Map<string, GraphCentralityRecord>;
|
||||
topNodeIds: string[];
|
||||
betweennessReady: boolean;
|
||||
};
|
||||
|
||||
const MAX_BETWEENNESS_NODES = 1400;
|
||||
const MAX_COMMUNITY_SUMMARIES = 6;
|
||||
const MAX_REGION_SUMMARIES = 6;
|
||||
const MAX_CENTRALITY_SUMMARIES = 6;
|
||||
const CENTRALITY_ITERATIONS = 24;
|
||||
const MAX_BACKBONE_ANCHORS = 4;
|
||||
const MAX_BACKBONE_CENTRAL_LINKS = 2;
|
||||
const MAX_BACKBONE_BRIDGES = 4;
|
||||
|
||||
function getNodeLabel(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
return String(attrs.label || attrs.content || nodeId);
|
||||
}
|
||||
|
||||
function getNodeColor(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
return String(attrs.baseColor || attrs.color || "#63E6FF");
|
||||
}
|
||||
|
||||
function getNodeSemanticGroup(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
return String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
}
|
||||
|
||||
function toVisibleNodeSet(graphRef: GraphRef, visibleNodeIds?: Iterable<string>): Set<string> {
|
||||
if (!visibleNodeIds) {
|
||||
return new Set(graphRef.nodes());
|
||||
}
|
||||
|
||||
const visible = new Set<string>();
|
||||
for (const nodeId of visibleNodeIds) {
|
||||
if (graphRef.hasNode(nodeId)) {
|
||||
visible.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return visible.size ? visible : new Set(graphRef.nodes());
|
||||
}
|
||||
|
||||
function buildCentralityRecords(
|
||||
graphRef: GraphRef,
|
||||
includeBetweenness: boolean,
|
||||
): Pick<GraphAnalyticsBase, "centralityByNode" | "topNodeIds" | "betweennessReady"> {
|
||||
const degree = degreeCentrality(graphRef);
|
||||
const inDegree = inDegreeCentrality(graphRef);
|
||||
const outDegree = outDegreeCentrality(graphRef);
|
||||
const betweenness = includeBetweenness
|
||||
? betweennessCentrality(graphRef, { normalized: true, getEdgeWeight: "weight" })
|
||||
: {};
|
||||
|
||||
const centralityByNode = new Map<string, GraphCentralityRecord>();
|
||||
graphRef.forEachNode((nodeId) => {
|
||||
const degreeScore = Number(degree[nodeId] ?? 0);
|
||||
const betweennessScore = Number((betweenness as Record<string, number>)[nodeId] ?? 0);
|
||||
const inDegreeScore = Number(inDegree[nodeId] ?? 0);
|
||||
const outDegreeScore = Number(outDegree[nodeId] ?? 0);
|
||||
centralityByNode.set(nodeId, {
|
||||
degree: degreeScore,
|
||||
inDegree: inDegreeScore,
|
||||
outDegree: outDegreeScore,
|
||||
betweenness: betweennessScore,
|
||||
score: degreeScore * 0.7 + betweennessScore * 0.3,
|
||||
});
|
||||
});
|
||||
|
||||
const topNodeIds = graphRef
|
||||
.nodes()
|
||||
.sort((left, right) => {
|
||||
const leftScore = centralityByNode.get(left)?.score ?? 0;
|
||||
const rightScore = centralityByNode.get(right)?.score ?? 0;
|
||||
if (rightScore !== leftScore) {
|
||||
return rightScore - leftScore;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
return {
|
||||
centralityByNode,
|
||||
topNodeIds,
|
||||
betweennessReady: includeBetweenness,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeGraphAnalyticsBase(
|
||||
graphRef: GraphRef,
|
||||
options?: {
|
||||
computeCommunities?: boolean;
|
||||
computeCentrality?: boolean;
|
||||
},
|
||||
): GraphAnalyticsBase {
|
||||
const shouldComputeCommunities = options?.computeCommunities ?? true;
|
||||
const shouldComputeCentrality = options?.computeCentrality ?? true;
|
||||
const communitiesByNode = new Map<string, number>();
|
||||
let communityCount = 0;
|
||||
let modularity: number | null = null;
|
||||
|
||||
if (shouldComputeCommunities && graphRef.order > 1 && graphRef.size > 0) {
|
||||
try {
|
||||
const result = louvain.detailed(graphRef, { getEdgeWeight: "weight" });
|
||||
modularity = Number(result.modularity ?? 0);
|
||||
communityCount = Number(result.count ?? 0);
|
||||
Object.entries(result.communities).forEach(([nodeId, communityId]) => {
|
||||
communitiesByNode.set(nodeId, Number(communityId));
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[GraphAnalytics] community detection failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldComputeCentrality || graphRef.order === 0) {
|
||||
return {
|
||||
communitiesByNode,
|
||||
communityCount,
|
||||
modularity,
|
||||
centralityByNode: new Map<string, GraphCentralityRecord>(),
|
||||
topNodeIds: [],
|
||||
betweennessReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
const includeBetweenness = graphRef.order <= MAX_BETWEENNESS_NODES;
|
||||
const centrality = buildCentralityRecords(graphRef, includeBetweenness);
|
||||
|
||||
return {
|
||||
communitiesByNode,
|
||||
communityCount,
|
||||
modularity,
|
||||
...centrality,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCommunitySummaries(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
base: GraphAnalyticsBase,
|
||||
): GraphCommunitySummary[] {
|
||||
const grouped = new Map<number, {
|
||||
nodeCount: number;
|
||||
visibleNodeCount: number;
|
||||
semanticCounts: Map<string, number>;
|
||||
anchorNodeId: string | null;
|
||||
prominence: number;
|
||||
color: string;
|
||||
}>();
|
||||
|
||||
graphRef.forEachNode((nodeId) => {
|
||||
const communityId = base.communitiesByNode.get(nodeId);
|
||||
if (communityId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = grouped.get(communityId) ?? {
|
||||
nodeCount: 0,
|
||||
visibleNodeCount: 0,
|
||||
semanticCounts: new Map<string, number>(),
|
||||
anchorNodeId: null,
|
||||
prominence: 0,
|
||||
color: getNodeColor(graphRef, nodeId),
|
||||
};
|
||||
|
||||
entry.nodeCount += 1;
|
||||
if (visibleNodeIds.has(nodeId)) {
|
||||
entry.visibleNodeCount += 1;
|
||||
}
|
||||
|
||||
const semanticGroup = getNodeSemanticGroup(graphRef, nodeId);
|
||||
entry.semanticCounts.set(semanticGroup, (entry.semanticCounts.get(semanticGroup) ?? 0) + 1);
|
||||
|
||||
const nodeScore = base.centralityByNode.get(nodeId)?.score ?? 0;
|
||||
if (!entry.anchorNodeId || nodeScore > (base.centralityByNode.get(entry.anchorNodeId)?.score ?? -1)) {
|
||||
entry.anchorNodeId = nodeId;
|
||||
entry.color = getNodeColor(graphRef, nodeId);
|
||||
}
|
||||
entry.prominence += visibleNodeIds.has(nodeId) ? 1 + nodeScore : nodeScore * 0.15;
|
||||
|
||||
grouped.set(communityId, entry);
|
||||
});
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([communityId, data]) => {
|
||||
const dominantSemanticGroup = [...data.semanticCounts.entries()]
|
||||
.sort((left, right) => right[1] - left[1])[0]?.[0] ?? "entity";
|
||||
return {
|
||||
communityId: String(communityId),
|
||||
nodeCount: data.nodeCount,
|
||||
visibleNodeCount: data.visibleNodeCount,
|
||||
dominantSemanticGroup,
|
||||
color: data.color,
|
||||
anchorNodeId: data.anchorNodeId,
|
||||
anchorLabel: data.anchorNodeId ? getNodeLabel(graphRef, data.anchorNodeId) : "Community anchor",
|
||||
prominence: data.prominence,
|
||||
};
|
||||
})
|
||||
.filter((summary) => summary.visibleNodeCount > 0)
|
||||
.sort((left, right) => right.prominence - left.prominence)
|
||||
.slice(0, MAX_COMMUNITY_SUMMARIES);
|
||||
}
|
||||
|
||||
function buildSemanticRegionSummaries(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
base: GraphAnalyticsBase,
|
||||
): GraphSemanticRegionSummary[] {
|
||||
const grouped = new Map<string, {
|
||||
nodeCount: number;
|
||||
visibleNodeCount: number;
|
||||
communityCounts: Map<string, number>;
|
||||
anchorNodeId: string | null;
|
||||
prominence: number;
|
||||
color: string;
|
||||
}>();
|
||||
|
||||
graphRef.forEachNode((nodeId) => {
|
||||
const semanticGroup = getNodeSemanticGroup(graphRef, nodeId);
|
||||
const entry = grouped.get(semanticGroup) ?? {
|
||||
nodeCount: 0,
|
||||
visibleNodeCount: 0,
|
||||
communityCounts: new Map<string, number>(),
|
||||
anchorNodeId: null,
|
||||
prominence: 0,
|
||||
color: getNodeColor(graphRef, nodeId),
|
||||
};
|
||||
|
||||
entry.nodeCount += 1;
|
||||
if (visibleNodeIds.has(nodeId)) {
|
||||
entry.visibleNodeCount += 1;
|
||||
}
|
||||
|
||||
const communityId = base.communitiesByNode.get(nodeId);
|
||||
if (communityId !== undefined) {
|
||||
const communityKey = String(communityId);
|
||||
entry.communityCounts.set(communityKey, (entry.communityCounts.get(communityKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const nodeScore = base.centralityByNode.get(nodeId)?.score ?? 0;
|
||||
if (!entry.anchorNodeId || nodeScore > (base.centralityByNode.get(entry.anchorNodeId)?.score ?? -1)) {
|
||||
entry.anchorNodeId = nodeId;
|
||||
entry.color = getNodeColor(graphRef, nodeId);
|
||||
}
|
||||
entry.prominence += visibleNodeIds.has(nodeId) ? 1 + nodeScore : nodeScore * 0.1;
|
||||
|
||||
grouped.set(semanticGroup, entry);
|
||||
});
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([semanticGroup, data]) => ({
|
||||
semanticGroup,
|
||||
nodeCount: data.nodeCount,
|
||||
visibleNodeCount: data.visibleNodeCount,
|
||||
color: data.color,
|
||||
anchorNodeId: data.anchorNodeId,
|
||||
anchorLabel: data.anchorNodeId ? getNodeLabel(graphRef, data.anchorNodeId) : semanticGroup,
|
||||
dominantCommunityId: [...data.communityCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null,
|
||||
prominence: data.prominence,
|
||||
}))
|
||||
.filter((summary) => summary.visibleNodeCount > 0)
|
||||
.sort((left, right) => right.prominence - left.prominence)
|
||||
.slice(0, MAX_REGION_SUMMARIES);
|
||||
}
|
||||
|
||||
function buildCentralitySummaries(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
base: GraphAnalyticsBase,
|
||||
): GraphCentralityNodeSummary[] {
|
||||
const candidateIds = base.topNodeIds.filter((nodeId) => visibleNodeIds.has(nodeId));
|
||||
const rankedIds = (candidateIds.length ? candidateIds : base.topNodeIds).slice(0, MAX_CENTRALITY_SUMMARIES);
|
||||
|
||||
return rankedIds.map((nodeId) => {
|
||||
const record = base.centralityByNode.get(nodeId) ?? {
|
||||
degree: 0,
|
||||
betweenness: 0,
|
||||
score: 0,
|
||||
};
|
||||
return {
|
||||
id: nodeId,
|
||||
label: getNodeLabel(graphRef, nodeId),
|
||||
semanticGroup: getNodeSemanticGroup(graphRef, nodeId),
|
||||
color: getNodeColor(graphRef, nodeId),
|
||||
degree: Number(record.degree ?? 0),
|
||||
betweenness: Number(record.betweenness ?? 0),
|
||||
score: Number(record.score ?? 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function collectNodeIncidentEdges(
|
||||
graphRef: GraphRef,
|
||||
nodeId: string,
|
||||
visibleNodeIds: Set<string>,
|
||||
): Array<{ edgeId: string; source: string; target: string; attrs: EdgeAttributes }> {
|
||||
const edges: Array<{ edgeId: string; source: string; target: string; attrs: EdgeAttributes }> = [];
|
||||
|
||||
graphRef.forEachOutEdge(nodeId, (edgeId, attrs, source, target) => {
|
||||
if (!visibleNodeIds.has(target)) {
|
||||
return;
|
||||
}
|
||||
edges.push({ edgeId: String(edgeId), source, target, attrs: attrs as EdgeAttributes });
|
||||
});
|
||||
|
||||
graphRef.forEachInEdge(nodeId, (edgeId, attrs, source, target) => {
|
||||
if (!visibleNodeIds.has(source)) {
|
||||
return;
|
||||
}
|
||||
edges.push({ edgeId: String(edgeId), source, target, attrs: attrs as EdgeAttributes });
|
||||
});
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
function scoreBackboneEdge(
|
||||
attrs: EdgeAttributes,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
base: GraphAnalyticsBase,
|
||||
) {
|
||||
const sourceScore = base.centralityByNode.get(sourceId)?.score ?? 0;
|
||||
const targetScore = base.centralityByNode.get(targetId)?.score ?? 0;
|
||||
const weight = Number(attrs.weight ?? 0);
|
||||
const priority = Number(attrs.visualPriority ?? 0);
|
||||
const parallelBoost = Number(attrs.parallelCount ?? 1) > 1 ? 0.14 : 0;
|
||||
const bidirectionalBoost = attrs.isBidirectional ? 0.18 : 0;
|
||||
|
||||
return weight * 1.4 + (sourceScore + targetScore) * 2.4 + priority * 0.6 + parallelBoost + bidirectionalBoost;
|
||||
}
|
||||
|
||||
function buildOverviewBackboneSnapshot(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
base: GraphAnalyticsBase,
|
||||
semanticRegionSummaries: GraphSemanticRegionSummary[],
|
||||
centralitySummaries: GraphCentralityNodeSummary[],
|
||||
): GraphAnalyticsSnapshot["overviewBackbone"] {
|
||||
if (visibleNodeIds.size === 0) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "No visible nodes are available for overview backbone selection.",
|
||||
edgeIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selectedEdgeIds = new Set<string>();
|
||||
const regionByNode = new Map<string, string>();
|
||||
visibleNodeIds.forEach((nodeId) => {
|
||||
regionByNode.set(nodeId, getNodeSemanticGroup(graphRef, nodeId));
|
||||
});
|
||||
const topRegionIds = new Set(semanticRegionSummaries.slice(0, 3).map((summary) => summary.semanticGroup));
|
||||
const backboneCoreNodeIds = new Set(
|
||||
centralitySummaries
|
||||
.slice(0, MAX_BACKBONE_ANCHORS * 2)
|
||||
.map((summary) => summary.id)
|
||||
.filter((nodeId) => visibleNodeIds.has(nodeId)),
|
||||
);
|
||||
|
||||
const anchorIds = centralitySummaries
|
||||
.slice(0, MAX_BACKBONE_ANCHORS)
|
||||
.map((summary) => summary.id)
|
||||
.filter((nodeId) => visibleNodeIds.has(nodeId));
|
||||
|
||||
const coreLinkCandidates = new Map<string, { edgeId: string; score: number }>();
|
||||
anchorIds.forEach((anchorId) => {
|
||||
collectNodeIncidentEdges(graphRef, anchorId, visibleNodeIds)
|
||||
.filter((entry) => {
|
||||
const otherNodeId = entry.source === anchorId ? entry.target : entry.source;
|
||||
if (!backboneCoreNodeIds.has(otherNodeId)) {
|
||||
return false;
|
||||
}
|
||||
const sourceRegion = regionByNode.get(entry.source);
|
||||
const targetRegion = regionByNode.get(entry.target);
|
||||
return Boolean(sourceRegion && targetRegion && (topRegionIds.has(sourceRegion) || topRegionIds.has(targetRegion)));
|
||||
})
|
||||
.forEach((entry) => {
|
||||
const pairKey = [entry.source, entry.target].sort().join("::");
|
||||
const sourceRegion = regionByNode.get(entry.source);
|
||||
const targetRegion = regionByNode.get(entry.target);
|
||||
const bridgeBoost = sourceRegion && targetRegion && sourceRegion !== targetRegion ? 0.28 : 0;
|
||||
const score = scoreBackboneEdge(entry.attrs, entry.source, entry.target, base) + bridgeBoost;
|
||||
const current = coreLinkCandidates.get(pairKey);
|
||||
if (!current || score > current.score || (score === current.score && entry.edgeId.localeCompare(current.edgeId) < 0)) {
|
||||
coreLinkCandidates.set(pairKey, { edgeId: entry.edgeId, score });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const bridgeByPair = new Map<string, { edgeId: string; score: number }>();
|
||||
graphRef.forEachEdge((edgeId, attrs, source, target) => {
|
||||
if (!visibleNodeIds.has(source) || !visibleNodeIds.has(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceRegion = regionByNode.get(source);
|
||||
const targetRegion = regionByNode.get(target);
|
||||
if (!sourceRegion || !targetRegion || sourceRegion === targetRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!topRegionIds.has(sourceRegion) && !topRegionIds.has(targetRegion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pairKey = [sourceRegion, targetRegion].sort().join("::");
|
||||
const score = scoreBackboneEdge(attrs as EdgeAttributes, source, target, base) + 0.36;
|
||||
const current = bridgeByPair.get(pairKey);
|
||||
if (!current || score > current.score || (score === current.score && String(edgeId).localeCompare(current.edgeId) < 0)) {
|
||||
bridgeByPair.set(pairKey, { edgeId: String(edgeId), score });
|
||||
}
|
||||
});
|
||||
|
||||
[...bridgeByPair.values()]
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
})
|
||||
.slice(0, MAX_BACKBONE_BRIDGES)
|
||||
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
|
||||
|
||||
[...coreLinkCandidates.values()]
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
})
|
||||
.slice(0, MAX_BACKBONE_CENTRAL_LINKS)
|
||||
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
|
||||
|
||||
const edgeIds = [...selectedEdgeIds]
|
||||
.filter((edgeId) => graphRef.hasEdge(edgeId))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
return {
|
||||
ready: edgeIds.length > 0,
|
||||
reason: edgeIds.length > 0
|
||||
? "Ready"
|
||||
: "No overview backbone edges met the current visibility thresholds.",
|
||||
edgeIds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirectedPathSnapshot(
|
||||
graphRef: GraphRef,
|
||||
interactionState: GraphInteractionState,
|
||||
): GraphAnalyticsSnapshot["directedPath"] {
|
||||
if (interactionState.activePath.length < 2) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "Trace a path to compare it against local strict directed shortest pathfinding.",
|
||||
sourceId: interactionState.selectedNodeId || null,
|
||||
targetId: null,
|
||||
path: [],
|
||||
length: null,
|
||||
verifiedAgainstActivePath: false,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceId = interactionState.activePath[0] ?? null;
|
||||
const targetId = interactionState.activePath[interactionState.activePath.length - 1] ?? null;
|
||||
if (!sourceId || !targetId || !graphRef.hasNode(sourceId) || !graphRef.hasNode(targetId)) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "Active path endpoints are not available in the current graph view.",
|
||||
sourceId,
|
||||
targetId,
|
||||
path: [],
|
||||
length: null,
|
||||
verifiedAgainstActivePath: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const path = dijkstra.bidirectional(graphRef, sourceId, targetId, "weight") ?? [];
|
||||
return {
|
||||
ready: path.length > 1,
|
||||
reason: path.length > 1
|
||||
? "Ready"
|
||||
: "No strict directed shortest path found in the current graph view.",
|
||||
sourceId,
|
||||
targetId,
|
||||
path,
|
||||
length: path.length > 1 ? path.length - 1 : null,
|
||||
verifiedAgainstActivePath: path.join("::") === interactionState.activePath.join("::"),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[GraphAnalytics] directed pathfinding failed", error);
|
||||
return {
|
||||
ready: false,
|
||||
reason: "Directed pathfinding failed for the current graph snapshot.",
|
||||
sourceId,
|
||||
targetId,
|
||||
path: [],
|
||||
length: null,
|
||||
verifiedAgainstActivePath: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGraphAnalyticsSnapshot(params: {
|
||||
graphRef: GraphRef;
|
||||
interactionState: GraphInteractionState;
|
||||
base: GraphAnalyticsBase;
|
||||
visibleNodeIds?: Iterable<string>;
|
||||
}): GraphAnalyticsSnapshot {
|
||||
const { graphRef, interactionState, base } = params;
|
||||
const visibleNodeIds = toVisibleNodeSet(graphRef, params.visibleNodeIds);
|
||||
const directedPath = buildDirectedPathSnapshot(graphRef, interactionState);
|
||||
const communitySummaries = base.communitiesByNode.size
|
||||
? buildCommunitySummaries(graphRef, visibleNodeIds, base)
|
||||
: [];
|
||||
const semanticRegionSummaries = buildSemanticRegionSummaries(graphRef, visibleNodeIds, base);
|
||||
const centralitySummaries = buildCentralitySummaries(graphRef, visibleNodeIds, base);
|
||||
const overviewBackbone = buildOverviewBackboneSnapshot(
|
||||
graphRef,
|
||||
visibleNodeIds,
|
||||
base,
|
||||
semanticRegionSummaries,
|
||||
centralitySummaries,
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: Date.now(),
|
||||
directedPath,
|
||||
communities: {
|
||||
ready: communitySummaries.length > 0,
|
||||
reason: communitySummaries.length > 0
|
||||
? "Ready"
|
||||
: base.communitiesByNode.size > 0
|
||||
? "No visible communities in the current graph context."
|
||||
: "Community detection has not produced summaries yet.",
|
||||
count: base.communityCount,
|
||||
modularity: base.modularity,
|
||||
summaries: communitySummaries,
|
||||
},
|
||||
centrality: {
|
||||
ready: centralitySummaries.length > 0,
|
||||
reason: centralitySummaries.length > 0
|
||||
? base.betweennessReady
|
||||
? "Ready"
|
||||
: "Ready (degree-biased while betweenness is bounded for large graphs)."
|
||||
: "Centrality ranking is waiting for graph data.",
|
||||
topNodes: centralitySummaries,
|
||||
},
|
||||
semanticRegions: {
|
||||
ready: semanticRegionSummaries.length > 0,
|
||||
reason: semanticRegionSummaries.length > 0
|
||||
? "Ready"
|
||||
: "No semantic regions are visible in the current graph context.",
|
||||
summaries: semanticRegionSummaries,
|
||||
},
|
||||
overviewBackbone,
|
||||
};
|
||||
}
|
||||
|
||||
export function colorForNodeKey(key: string): string {
|
||||
const palette = GRAPH_THEME.palette.semantic;
|
||||
return palette[hashString(key) % palette.length];
|
||||
}
|
||||
|
||||
export function chooseColorAccessor(
|
||||
nodes: Array<{ id: string; attributes: Pick<NodeAttributes, "nodeType" | "content"> }>,
|
||||
) {
|
||||
return (id: string, attributes: Pick<NodeAttributes, "nodeType" | "content">) => {
|
||||
const semanticKey = String(attributes.nodeType || attributes.content || id);
|
||||
const node = nodes.find((entry) => entry.id === id);
|
||||
const fallbackKey = String(node?.attributes.nodeType || node?.attributes.content || semanticKey);
|
||||
return `${fallbackKey}:${id}`;
|
||||
};
|
||||
}
|
||||
|
||||
export function deterministicPosition(nodeId: string, index: number, totalNodes: number) {
|
||||
const angle = (hashString(nodeId) % 360) * (Math.PI / 180);
|
||||
const ring = Math.floor(index / Math.max(12, Math.ceil(Math.sqrt(Math.max(totalNodes, 1)))));
|
||||
const radius = 120 + ring * 90 + (hashString(`${nodeId}:radius`) % 46);
|
||||
return {
|
||||
x: Math.cos(angle) * radius,
|
||||
y: Math.sin(angle) * radius,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeDegreeMap(
|
||||
nodes: GraphDataSnapshot["nodes"],
|
||||
edges: GraphDataSnapshot["edges"],
|
||||
) {
|
||||
const degreeByNode = new Map<string, number>();
|
||||
nodes.forEach((node) => degreeByNode.set(node.id, 0));
|
||||
edges.forEach((edge) => {
|
||||
degreeByNode.set(edge.source, (degreeByNode.get(edge.source) ?? 0) + 1);
|
||||
degreeByNode.set(edge.target, (degreeByNode.get(edge.target) ?? 0) + 1);
|
||||
});
|
||||
return degreeByNode;
|
||||
}
|
||||
|
||||
export function computePageRank(
|
||||
nodes: GraphDataSnapshot["nodes"],
|
||||
edges: GraphDataSnapshot["edges"],
|
||||
) {
|
||||
const pageRank = new Map<string, number>();
|
||||
const outbound = new Map<string, string[]>();
|
||||
const inbound = new Map<string, string[]>();
|
||||
const nodeCount = Math.max(nodes.length, 1);
|
||||
|
||||
nodes.forEach((node) => {
|
||||
pageRank.set(node.id, 1 / nodeCount);
|
||||
outbound.set(node.id, []);
|
||||
inbound.set(node.id, []);
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
outbound.set(edge.source, [...(outbound.get(edge.source) ?? []), edge.target]);
|
||||
inbound.set(edge.target, [...(inbound.get(edge.target) ?? []), edge.source]);
|
||||
});
|
||||
|
||||
for (let iteration = 0; iteration < CENTRALITY_ITERATIONS; iteration += 1) {
|
||||
const next = new Map<string, number>();
|
||||
nodes.forEach((node) => {
|
||||
const incoming = inbound.get(node.id) ?? [];
|
||||
let sum = 0;
|
||||
incoming.forEach((sourceId) => {
|
||||
const outDegree = (outbound.get(sourceId) ?? []).length || nodeCount;
|
||||
sum += (pageRank.get(sourceId) ?? 0) / outDegree;
|
||||
});
|
||||
next.set(node.id, 0.15 / nodeCount + 0.85 * sum);
|
||||
});
|
||||
next.forEach((value, nodeId) => pageRank.set(nodeId, value));
|
||||
}
|
||||
|
||||
return pageRank;
|
||||
}
|
||||
|
||||
export function computeNodeSize(
|
||||
nodeId: string,
|
||||
degreeByNode: Map<string, number>,
|
||||
pageRankByNode: Map<string, number>,
|
||||
) {
|
||||
const degree = degreeByNode.get(nodeId) ?? 0;
|
||||
const pageRank = pageRankByNode.get(nodeId) ?? 0;
|
||||
return 5.5 + Math.min(16, degree * 0.18 + pageRank * 160);
|
||||
}
|
||||
|
||||
export function computeEdgeSize(weight: number) {
|
||||
return Math.max(0.8, Math.min(3.2, 0.9 + Math.log2(Math.max(weight, 1) + 1) * 0.36));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export const GRAPH_THEME = {
|
||||
background: {
|
||||
canvas: "#050816",
|
||||
shell: "#0A1021",
|
||||
panel: "#10182C",
|
||||
grid: "rgba(93, 124, 168, 0.08)",
|
||||
vignette: "rgba(2, 4, 10, 0.88)",
|
||||
},
|
||||
nodes: {
|
||||
palette: [
|
||||
"#3CE7FF",
|
||||
"#23D7C8",
|
||||
"#5DA9FF",
|
||||
"#9B6BFF",
|
||||
"#FF4FD8",
|
||||
"#FF9A3C",
|
||||
"#B9FF3B",
|
||||
],
|
||||
selected: "#FFC857",
|
||||
selectedGlow: "rgba(255, 200, 87, 0.36)",
|
||||
hoverGlow: "rgba(60, 231, 255, 0.32)",
|
||||
border: "#07111C",
|
||||
subduedAlpha: 0.1,
|
||||
},
|
||||
edges: {
|
||||
baseColor: "rgba(126, 162, 214, 0.08)",
|
||||
subduedColor: "rgba(126, 162, 214, 0.03)",
|
||||
hoverColor: "rgba(94, 198, 255, 0.9)",
|
||||
pathColor: "rgba(255, 192, 92, 0.95)",
|
||||
focusColor: "rgba(175, 191, 255, 0.26)",
|
||||
},
|
||||
motion: {
|
||||
hoverMs: 160,
|
||||
cameraMs: 480,
|
||||
},
|
||||
thresholds: {
|
||||
interactiveLayoutMaxNodes: 8000,
|
||||
stagedLayoutMaxNodes: 25000,
|
||||
focusNeighborCap: 18,
|
||||
focusPrimaryLabels: 8,
|
||||
particleEdgeCap: 32,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const FORCE_ATLAS_SETTINGS = {
|
||||
getEdgeWeight: "weight",
|
||||
settings: {
|
||||
barnesHutOptimize: true,
|
||||
barnesHutTheta: 0.6,
|
||||
linLogMode: true,
|
||||
outboundAttractionDistribution: true,
|
||||
strongGravityMode: false,
|
||||
gravity: 0.14,
|
||||
scalingRatio: 4.8,
|
||||
slowDown: 6,
|
||||
edgeWeightInfluence: 1,
|
||||
adjustSizes: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function clamp(min: number, value: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function withAlpha(color: string | undefined, alpha: number): string {
|
||||
if (!color) {
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
|
||||
if (normalized.length === 6) {
|
||||
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (color.startsWith("rgba(")) {
|
||||
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
}
|
||||
|
||||
if (color.startsWith("rgb(")) {
|
||||
return color.replace("rgb(", "rgba(").replace(")", `, ${alpha})`);
|
||||
}
|
||||
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { GraphLoadPhase, GraphLoadProgress, GraphLoadProgressKind, GraphLayoutSource, GraphLayoutState } from "./types";
|
||||
|
||||
export const GRAPH_LOAD_STAGE_SEQUENCE: Exclude<GraphLoadPhase, "ready">[] = [
|
||||
"bootstrapping",
|
||||
"fetching_nodes",
|
||||
"fetching_edges",
|
||||
"computing_styling",
|
||||
"hydrating_scene",
|
||||
"stabilizing_layout",
|
||||
];
|
||||
|
||||
export function getGraphLoadTitle(phase: GraphLoadPhase): string {
|
||||
switch (phase) {
|
||||
case "bootstrapping":
|
||||
return "Preparing graph session";
|
||||
case "fetching_nodes":
|
||||
return "Loading nodes";
|
||||
case "fetching_edges":
|
||||
return "Loading relationships";
|
||||
case "computing_styling":
|
||||
return "Computing node styling";
|
||||
case "hydrating_scene":
|
||||
return "Hydrating graph scene";
|
||||
case "stabilizing_layout":
|
||||
return "Stabilizing layout";
|
||||
case "ready":
|
||||
default:
|
||||
return "Graph ready";
|
||||
}
|
||||
}
|
||||
|
||||
export function getGraphLoadStageLabel(phase: Exclude<GraphLoadPhase, "ready">): string {
|
||||
switch (phase) {
|
||||
case "bootstrapping":
|
||||
return "Prepare";
|
||||
case "fetching_nodes":
|
||||
return "Nodes";
|
||||
case "fetching_edges":
|
||||
return "Relations";
|
||||
case "computing_styling":
|
||||
return "Styling";
|
||||
case "hydrating_scene":
|
||||
return "Scene";
|
||||
case "stabilizing_layout":
|
||||
return "Layout";
|
||||
default:
|
||||
return "Stage";
|
||||
}
|
||||
}
|
||||
|
||||
export function createGraphLoadProgress(input: {
|
||||
phase: GraphLoadPhase;
|
||||
message: string;
|
||||
progressKind: GraphLoadProgressKind;
|
||||
loaded?: number | null;
|
||||
total?: number | null;
|
||||
nodesLoaded?: number;
|
||||
nodesTotal?: number | null;
|
||||
edgesLoaded?: number;
|
||||
edgesTotal?: number | null;
|
||||
showGraphBehind?: boolean;
|
||||
layoutSource?: GraphLayoutSource;
|
||||
layoutState?: GraphLayoutState;
|
||||
}): GraphLoadProgress {
|
||||
const phaseIndex = GRAPH_LOAD_STAGE_SEQUENCE.indexOf(input.phase as Exclude<GraphLoadPhase, "ready">);
|
||||
|
||||
return {
|
||||
phase: input.phase,
|
||||
title: getGraphLoadTitle(input.phase),
|
||||
message: input.message,
|
||||
progressKind: input.progressKind,
|
||||
loaded: input.loaded ?? null,
|
||||
total: input.total ?? null,
|
||||
nodesLoaded: input.nodesLoaded ?? 0,
|
||||
nodesTotal: input.nodesTotal ?? null,
|
||||
edgesLoaded: input.edgesLoaded ?? 0,
|
||||
edgesTotal: input.edgesTotal ?? null,
|
||||
showGraphBehind: input.showGraphBehind ?? false,
|
||||
stageIndex: phaseIndex >= 0 ? phaseIndex + 1 : GRAPH_LOAD_STAGE_SEQUENCE.length,
|
||||
stageCount: GRAPH_LOAD_STAGE_SEQUENCE.length,
|
||||
layoutSource: input.layoutSource,
|
||||
layoutState: input.layoutState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import type Graph from "graphology";
|
||||
import Sigma from "sigma";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import { blendHex, GRAPH_THEME, withAlpha, zoomTierAtLeast } from "./graphTheme";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
GraphTemporalState,
|
||||
} from "./types";
|
||||
|
||||
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
|
||||
export type ViewportPoint = { x: number; y: number };
|
||||
|
||||
export type PathSegmentOverlay = {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
source: ViewportPoint;
|
||||
target: ViewportPoint;
|
||||
color: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type VisibleNodeSample = {
|
||||
nodeId: string;
|
||||
point: ViewportPoint;
|
||||
size: number;
|
||||
attrs: NodeAttributes;
|
||||
};
|
||||
|
||||
function isPointNearViewport(point: ViewportPoint, width: number, height: number, padding = 96) {
|
||||
return point.x >= -padding
|
||||
&& point.y >= -padding
|
||||
&& point.x <= width + padding
|
||||
&& point.y <= height + padding;
|
||||
}
|
||||
|
||||
function drawGlowHalo(
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
color: string,
|
||||
) {
|
||||
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
context.arc(x, y, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function createScratchCanvas(width: number, height: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function parseCssColor(color: string) {
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
if (normalized.length === 6) {
|
||||
return {
|
||||
r: Number.parseInt(normalized.slice(0, 2), 16),
|
||||
g: Number.parseInt(normalized.slice(2, 4), 16),
|
||||
b: Number.parseInt(normalized.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||
if (match) {
|
||||
return {
|
||||
r: Number.parseInt(match[1], 10),
|
||||
g: Number.parseInt(match[2], 10),
|
||||
b: Number.parseInt(match[3], 10),
|
||||
};
|
||||
}
|
||||
|
||||
return { r: 130, g: 145, b: 165 };
|
||||
}
|
||||
|
||||
function createThresholdMask(
|
||||
alphaValues: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
threshold: number,
|
||||
) {
|
||||
const mask = new Uint8Array(width * height);
|
||||
let count = 0;
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let sumWeight = 0;
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = y * width + x;
|
||||
const alpha = alphaValues[index];
|
||||
if (alpha < threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mask[index] = 1;
|
||||
count += 1;
|
||||
sumX += x * alpha;
|
||||
sumY += y * alpha;
|
||||
sumWeight += alpha;
|
||||
}
|
||||
}
|
||||
|
||||
const centroid = sumWeight > 0
|
||||
? { x: sumX / sumWeight, y: sumY / sumWeight }
|
||||
: { x: width / 2, y: height / 2 };
|
||||
|
||||
return { mask, count, centroid };
|
||||
}
|
||||
|
||||
function blurAlphaValues(
|
||||
alphaValues: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
passes: number,
|
||||
) {
|
||||
if (passes <= 0) {
|
||||
return alphaValues;
|
||||
}
|
||||
|
||||
let source = alphaValues;
|
||||
for (let pass = 0; pass < passes; pass += 1) {
|
||||
const next = new Uint8ClampedArray(width * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let sum = 0;
|
||||
let samples = 0;
|
||||
for (let oy = -1; oy <= 1; oy += 1) {
|
||||
const sampleY = y + oy;
|
||||
if (sampleY < 0 || sampleY >= height) {
|
||||
continue;
|
||||
}
|
||||
for (let ox = -1; ox <= 1; ox += 1) {
|
||||
const sampleX = x + ox;
|
||||
if (sampleX < 0 || sampleX >= width) {
|
||||
continue;
|
||||
}
|
||||
sum += source[sampleY * width + sampleX];
|
||||
samples += 1;
|
||||
}
|
||||
}
|
||||
next[y * width + x] = Math.round(sum / Math.max(samples, 1));
|
||||
}
|
||||
}
|
||||
source = next;
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
function isolateDominantMask(
|
||||
mask: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
alphaValues: Uint8ClampedArray,
|
||||
) {
|
||||
const visited = new Uint8Array(mask.length);
|
||||
const queue = new Int32Array(mask.length);
|
||||
let bestPixels: number[] = [];
|
||||
let totalCount = 0;
|
||||
|
||||
for (let index = 0; index < mask.length; index += 1) {
|
||||
if (!mask[index] || visited[index]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
const component: number[] = [];
|
||||
visited[index] = 1;
|
||||
queue[tail++] = index;
|
||||
|
||||
while (head < tail) {
|
||||
const current = queue[head++];
|
||||
component.push(current);
|
||||
|
||||
const x = current % width;
|
||||
const y = Math.floor(current / width);
|
||||
const neighbors = [
|
||||
current - 1,
|
||||
current + 1,
|
||||
current - width,
|
||||
current + width,
|
||||
];
|
||||
|
||||
for (let n = 0; n < neighbors.length; n += 1) {
|
||||
const neighbor = neighbors[n];
|
||||
if (neighbor < 0 || neighbor >= mask.length || visited[neighbor] || !mask[neighbor]) {
|
||||
continue;
|
||||
}
|
||||
if ((n === 0 && x === 0) || (n === 1 && x === width - 1) || (n === 2 && y === 0) || (n === 3 && y === height - 1)) {
|
||||
continue;
|
||||
}
|
||||
visited[neighbor] = 1;
|
||||
queue[tail++] = neighbor;
|
||||
}
|
||||
}
|
||||
|
||||
totalCount += component.length;
|
||||
if (component.length > bestPixels.length) {
|
||||
bestPixels = component;
|
||||
}
|
||||
}
|
||||
|
||||
const dominantMask = new Uint8Array(mask.length);
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let sumWeight = 0;
|
||||
bestPixels.forEach((index) => {
|
||||
dominantMask[index] = 1;
|
||||
const alpha = alphaValues[index];
|
||||
const x = index % width;
|
||||
const y = Math.floor(index / width);
|
||||
sumX += x * alpha;
|
||||
sumY += y * alpha;
|
||||
sumWeight += alpha;
|
||||
});
|
||||
|
||||
return {
|
||||
mask: dominantMask,
|
||||
count: bestPixels.length,
|
||||
centroid: sumWeight > 0
|
||||
? { x: sumX / sumWeight, y: sumY / sumWeight }
|
||||
: { x: width / 2, y: height / 2 },
|
||||
occupancyRatio: bestPixels.length / Math.max(width * height, 1),
|
||||
dominantMassRatio: bestPixels.length / Math.max(totalCount, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function renderDensityField(
|
||||
samples: VisibleNodeSample[],
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const scale = GRAPH_THEME.effects.semanticRegions.fogResolutionScale;
|
||||
const gridWidth = Math.max(72, Math.round(width * scale));
|
||||
const gridHeight = Math.max(72, Math.round(height * scale));
|
||||
const canvas = createScratchCanvas(gridWidth, gridHeight);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, gridWidth, gridHeight);
|
||||
context.globalCompositeOperation = "source-over";
|
||||
|
||||
samples.forEach((sample) => {
|
||||
const x = sample.point.x * scale;
|
||||
const y = sample.point.y * scale;
|
||||
const radius = Math.max(
|
||||
2.5,
|
||||
(GRAPH_THEME.effects.semanticRegions.splatRadius + sample.size * 0.9) * scale,
|
||||
);
|
||||
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0.22)");
|
||||
gradient.addColorStop(0.58, "rgba(255,255,255,0.08)");
|
||||
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
context.arc(x, y, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
|
||||
const imageData = context.getImageData(0, 0, gridWidth, gridHeight);
|
||||
const alphaValues = new Uint8ClampedArray(gridWidth * gridHeight);
|
||||
let maxAlpha = 0;
|
||||
for (let index = 0; index < alphaValues.length; index += 1) {
|
||||
const alpha = imageData.data[index * 4 + 3];
|
||||
alphaValues[index] = alpha;
|
||||
if (alpha > maxAlpha) {
|
||||
maxAlpha = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxAlpha <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blurredAlphaValues = blurAlphaValues(
|
||||
alphaValues,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
GRAPH_THEME.effects.semanticRegions.blurPasses,
|
||||
);
|
||||
const blurredMaxAlpha = blurredAlphaValues.reduce((value, alpha) => Math.max(value, alpha), 0);
|
||||
if (blurredMaxAlpha <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { canvas, gridWidth, gridHeight, alphaValues: blurredAlphaValues, maxAlpha: blurredMaxAlpha, scale };
|
||||
}
|
||||
|
||||
function drawDensityFog(
|
||||
context: CanvasRenderingContext2D,
|
||||
densityField: ReturnType<typeof renderDensityField>,
|
||||
color: string,
|
||||
alpha: number,
|
||||
) {
|
||||
if (!densityField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { gridWidth, gridHeight, alphaValues } = densityField;
|
||||
const fogCanvas = createScratchCanvas(gridWidth, gridHeight);
|
||||
const fogContext = fogCanvas.getContext("2d");
|
||||
if (!fogContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = fogContext.createImageData(gridWidth, gridHeight);
|
||||
const rgb = parseCssColor(color);
|
||||
for (let index = 0; index < alphaValues.length; index += 1) {
|
||||
const sourceAlpha = alphaValues[index] / 255;
|
||||
if (sourceAlpha <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pixelIndex = index * 4;
|
||||
imageData.data[pixelIndex] = rgb.r;
|
||||
imageData.data[pixelIndex + 1] = rgb.g;
|
||||
imageData.data[pixelIndex + 2] = rgb.b;
|
||||
imageData.data[pixelIndex + 3] = Math.round(255 * Math.pow(sourceAlpha, 1.7) * alpha);
|
||||
}
|
||||
|
||||
fogContext.putImageData(imageData, 0, 0);
|
||||
context.save();
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.globalCompositeOperation = "lighter";
|
||||
context.drawImage(fogCanvas, 0, 0, context.canvas.width, context.canvas.height);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawMaskContour(
|
||||
context: CanvasRenderingContext2D,
|
||||
mask: Uint8Array,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
color: string,
|
||||
alpha: number,
|
||||
) {
|
||||
const contourCanvas = createScratchCanvas(gridWidth, gridHeight);
|
||||
const contourContext = contourCanvas.getContext("2d");
|
||||
if (!contourContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = contourContext.createImageData(gridWidth, gridHeight);
|
||||
const rgb = parseCssColor(color);
|
||||
|
||||
for (let y = 1; y < gridHeight - 1; y += 1) {
|
||||
for (let x = 1; x < gridWidth - 1; x += 1) {
|
||||
const index = y * gridWidth + x;
|
||||
if (!mask[index]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isEdge = !mask[index - 1]
|
||||
|| !mask[index + 1]
|
||||
|| !mask[index - gridWidth]
|
||||
|| !mask[index + gridWidth];
|
||||
if (!isEdge) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pixelIndex = index * 4;
|
||||
imageData.data[pixelIndex] = rgb.r;
|
||||
imageData.data[pixelIndex + 1] = rgb.g;
|
||||
imageData.data[pixelIndex + 2] = rgb.b;
|
||||
imageData.data[pixelIndex + 3] = Math.round(255 * alpha);
|
||||
}
|
||||
}
|
||||
|
||||
contourContext.putImageData(imageData, 0, 0);
|
||||
context.save();
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.drawImage(contourCanvas, 0, 0, context.canvas.width, context.canvas.height);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function buildRegionRenderColors(color: string) {
|
||||
return {
|
||||
fogColor: blendHex("#0E1927", color, 0.46),
|
||||
innerContourColor: blendHex("#1C2C3F", color, 0.78),
|
||||
outerContourColor: blendHex("#132131", color, 0.6),
|
||||
labelBorderColor: blendHex("#2B3F57", color, 0.78),
|
||||
};
|
||||
}
|
||||
|
||||
export function collectVisibleNodeSamples(
|
||||
sigma: Sigma,
|
||||
graphRef: GraphRef,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
): VisibleNodeSample[] {
|
||||
const samples: VisibleNodeSample[] = [];
|
||||
|
||||
graphRef.forEachNode((nodeId, attrs) => {
|
||||
const displayData = sigma.getNodeDisplayData(nodeId);
|
||||
if (!displayData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = sigma.graphToViewport({ x: displayData.x, y: displayData.y });
|
||||
if (!isPointNearViewport(point, viewportWidth, viewportHeight, 84)) {
|
||||
return;
|
||||
}
|
||||
|
||||
samples.push({
|
||||
nodeId,
|
||||
point,
|
||||
size: displayData.size,
|
||||
attrs: attrs as NodeAttributes,
|
||||
});
|
||||
});
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
function drawRegionLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
color: string,
|
||||
) {
|
||||
const label = text.length > 22 ? `${text.slice(0, 21)}…` : text;
|
||||
context.save();
|
||||
context.font = `600 11px "IBM Plex Sans", Inter, system-ui, sans-serif`;
|
||||
context.textBaseline = "middle";
|
||||
const width = context.measureText(label).width + 14;
|
||||
const height = 22;
|
||||
const left = x - width / 2;
|
||||
const top = y - height / 2;
|
||||
|
||||
context.fillStyle = "rgba(8, 14, 24, 0.84)";
|
||||
context.strokeStyle = withAlpha(color, 0.24);
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.roundRect(left, top, width, height, 999);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
|
||||
context.fillStyle = "rgba(235, 244, 255, 0.88)";
|
||||
context.fillText(label, left + 7, y);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
export function drawSemanticRegionsLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
analytics: GraphAnalyticsSnapshot | null,
|
||||
visibleNodes: VisibleNodeSample[],
|
||||
interactionState: GraphInteractionState,
|
||||
effectsState: GraphEffectsState,
|
||||
) {
|
||||
if (!effectsState.semanticRegionsEnabled || !analytics?.semanticRegions.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.semanticRegions.minZoomTier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleByGroup = new Map<string, VisibleNodeSample[]>();
|
||||
visibleNodes.forEach((sample) => {
|
||||
const group = String(sample.attrs.semanticGroup || sample.attrs.nodeType || "entity");
|
||||
const entry = visibleByGroup.get(group);
|
||||
if (entry) {
|
||||
entry.push(sample);
|
||||
} else {
|
||||
visibleByGroup.set(group, [sample]);
|
||||
}
|
||||
});
|
||||
|
||||
const summaries = analytics.semanticRegions.summaries
|
||||
.slice(0, GRAPH_THEME.effects.semanticRegions.maxRegions);
|
||||
const maxProminence = summaries.reduce((value, summary) => Math.max(value, summary.prominence), 1);
|
||||
const semanticConfig = GRAPH_THEME.effects.semanticRegions;
|
||||
|
||||
summaries.forEach((summary) => {
|
||||
const samples = visibleByGroup.get(summary.semanticGroup);
|
||||
if (!samples || samples.length < semanticConfig.minVisibleSamples) {
|
||||
return;
|
||||
}
|
||||
|
||||
const densityField = renderDensityField(samples, context.canvas.width, context.canvas.height);
|
||||
if (!densityField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prominenceRatio = 0.52 + (summary.prominence / maxProminence) * 0.48;
|
||||
const densityThreshold = Math.max(18, densityField.maxAlpha * semanticConfig.densityThreshold);
|
||||
const contourThreshold = Math.max(28, densityField.maxAlpha * semanticConfig.contourThreshold);
|
||||
const outerContourThreshold = Math.max(18, contourThreshold * 0.72);
|
||||
|
||||
const densityMask = createThresholdMask(
|
||||
densityField.alphaValues,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
densityThreshold,
|
||||
);
|
||||
const dominantDensityMask = isolateDominantMask(
|
||||
densityMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
densityField.alphaValues,
|
||||
);
|
||||
if (
|
||||
dominantDensityMask.count < semanticConfig.minMaskPixels
|
||||
|| dominantDensityMask.occupancyRatio < semanticConfig.minOccupancyRatio
|
||||
|| dominantDensityMask.dominantMassRatio < semanticConfig.dominantMassRatio
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contourMask = createThresholdMask(
|
||||
densityField.alphaValues,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
contourThreshold,
|
||||
);
|
||||
const outerContourMask = createThresholdMask(
|
||||
densityField.alphaValues,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
outerContourThreshold,
|
||||
);
|
||||
const dominantContourMask = isolateDominantMask(
|
||||
contourMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
densityField.alphaValues,
|
||||
);
|
||||
const dominantOuterContourMask = isolateDominantMask(
|
||||
outerContourMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
densityField.alphaValues,
|
||||
);
|
||||
|
||||
const colors = buildRegionRenderColors(summary.color);
|
||||
drawDensityFog(
|
||||
context,
|
||||
densityField,
|
||||
colors.fogColor,
|
||||
semanticConfig.fogAlpha * prominenceRatio,
|
||||
);
|
||||
if (
|
||||
dominantOuterContourMask.count >= semanticConfig.outerContourMinMaskPixels
|
||||
&& dominantOuterContourMask.dominantMassRatio >= semanticConfig.dominantMassRatio
|
||||
) {
|
||||
drawMaskContour(
|
||||
context,
|
||||
dominantOuterContourMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
colors.outerContourColor,
|
||||
semanticConfig.outerContourAlpha * prominenceRatio,
|
||||
);
|
||||
}
|
||||
drawMaskContour(
|
||||
context,
|
||||
dominantContourMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
colors.innerContourColor,
|
||||
semanticConfig.innerContourAlpha * prominenceRatio,
|
||||
);
|
||||
|
||||
if (summary.visibleNodeCount >= 18) {
|
||||
const labelX = dominantDensityMask.centroid.x / densityField.scale;
|
||||
const labelY = dominantDensityMask.centroid.y / densityField.scale - 18;
|
||||
drawRegionLabel(
|
||||
context,
|
||||
labelX,
|
||||
labelY,
|
||||
summary.semanticGroup,
|
||||
colors.labelBorderColor,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function drawContourLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
analytics: GraphAnalyticsSnapshot | null,
|
||||
visibleNodes: VisibleNodeSample[],
|
||||
interactionState: GraphInteractionState,
|
||||
effectsState: GraphEffectsState,
|
||||
) {
|
||||
if (!effectsState.contoursEnabled || !analytics?.centrality.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.contours.minZoomTier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleById = new Map(visibleNodes.map((sample) => [sample.nodeId, sample] as const));
|
||||
|
||||
analytics.centrality.topNodes.slice(0, GRAPH_THEME.effects.contours.maxContours).forEach((node) => {
|
||||
const sample = visibleById.get(node.id);
|
||||
if (!sample) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawGlowHalo(
|
||||
context,
|
||||
sample.point.x,
|
||||
sample.point.y,
|
||||
GRAPH_THEME.effects.contours.baseRadius + sample.size * 3.2,
|
||||
withAlpha(node.color, GRAPH_THEME.effects.contours.glowAlpha),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isTemporalNodeActive(attrs: NodeAttributes, currentTime: Date | null) {
|
||||
if (!currentTime || (!attrs.valid_from && !attrs.valid_until)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const time = currentTime.getTime();
|
||||
const from = attrs.valid_from ? new Date(attrs.valid_from).getTime() : Number.NEGATIVE_INFINITY;
|
||||
const until = attrs.valid_until ? new Date(attrs.valid_until).getTime() : Number.POSITIVE_INFINITY;
|
||||
return time >= from && time <= until;
|
||||
}
|
||||
|
||||
export function drawTemporalEmphasisLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
visibleNodes: VisibleNodeSample[],
|
||||
temporalState: GraphTemporalState | null | undefined,
|
||||
interactionState: GraphInteractionState,
|
||||
effectsState: GraphEffectsState,
|
||||
) {
|
||||
if (!effectsState.temporalEmphasisEnabled || !temporalState?.currentTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.temporalEmphasis.minZoomTier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
visibleNodes
|
||||
.filter((sample) => isTemporalNodeActive(sample.attrs, temporalState.currentTime))
|
||||
.slice(0, GRAPH_THEME.effects.temporalEmphasis.maxHighlights)
|
||||
.forEach((sample) => {
|
||||
drawGlowHalo(
|
||||
context,
|
||||
sample.point.x,
|
||||
sample.point.y,
|
||||
Math.max(sample.size * GRAPH_THEME.effects.temporalEmphasis.radiusMultiplier, 14),
|
||||
withAlpha(GRAPH_THEME.palette.accent.temporal, GRAPH_THEME.effects.temporalEmphasis.glowAlpha),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function drawLensLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
sigma: Sigma,
|
||||
primaryNodeId: string,
|
||||
focusIds: Set<string>,
|
||||
) {
|
||||
const primaryData = sigma.getNodeDisplayData(primaryNodeId);
|
||||
if (!primaryData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const center = sigma.graphToViewport({ x: primaryData.x, y: primaryData.y });
|
||||
drawGlowHalo(
|
||||
context,
|
||||
center.x,
|
||||
center.y,
|
||||
GRAPH_THEME.effects.lens.radius,
|
||||
withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.glowAlpha),
|
||||
);
|
||||
|
||||
focusIds.forEach((neighborId) => {
|
||||
if (neighborId === primaryNodeId || !graph.hasNode(neighborId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!graph.hasDirectedEdge(primaryNodeId, neighborId)
|
||||
&& !graph.hasDirectedEdge(neighborId, primaryNodeId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const neighborData = sigma.getNodeDisplayData(neighborId);
|
||||
if (!neighborData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const neighborPoint = sigma.graphToViewport({ x: neighborData.x, y: neighborData.y });
|
||||
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.edgeAlpha * 0.38);
|
||||
context.lineWidth = GRAPH_THEME.effects.lens.edgeLineWidth + 3.2;
|
||||
context.lineCap = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(center.x, center.y);
|
||||
context.lineTo(neighborPoint.x, neighborPoint.y);
|
||||
context.stroke();
|
||||
|
||||
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.edgeAlpha);
|
||||
context.lineWidth = GRAPH_THEME.effects.lens.edgeLineWidth;
|
||||
context.beginPath();
|
||||
context.moveTo(center.x, center.y);
|
||||
context.lineTo(neighborPoint.x, neighborPoint.y);
|
||||
context.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
export function drawPathEffectsLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
segments: PathSegmentOverlay[],
|
||||
effectsState: GraphEffectsState,
|
||||
effectAvailability: {
|
||||
pathPulse: { available: boolean };
|
||||
pathFlow: { available: boolean };
|
||||
},
|
||||
now: number,
|
||||
) {
|
||||
if (effectsState.pathFlowEnabled && effectAvailability.pathFlow.available) {
|
||||
segments.forEach((segment, index) => {
|
||||
const t = ((now * GRAPH_THEME.effects.pathFlow.speed) + index * GRAPH_THEME.effects.pathFlow.spacing) % 1;
|
||||
const headX = segment.source.x + (segment.target.x - segment.source.x) * t;
|
||||
const headY = segment.source.y + (segment.target.y - segment.source.y) * t;
|
||||
const tailT = Math.max(0, t - 0.08);
|
||||
const tailX = segment.source.x + (segment.target.x - segment.source.x) * tailT;
|
||||
const tailY = segment.source.y + (segment.target.y - segment.source.y) * tailT;
|
||||
|
||||
context.strokeStyle = withAlpha(segment.color, 0.28);
|
||||
context.lineWidth = Math.max(segment.size + 3.6, 4.6);
|
||||
context.lineCap = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(tailX, tailY);
|
||||
context.lineTo(headX, headY);
|
||||
context.stroke();
|
||||
|
||||
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.selected, GRAPH_THEME.effects.pathFlow.opacity);
|
||||
context.lineWidth = Math.max(segment.size + 1.3, 2.4);
|
||||
context.beginPath();
|
||||
context.moveTo(tailX, tailY);
|
||||
context.lineTo(headX, headY);
|
||||
context.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
if (effectsState.pathPulseEnabled && effectAvailability.pathPulse.available) {
|
||||
segments.forEach((segment, index) => {
|
||||
const t = ((now * GRAPH_THEME.effects.pathPulse.speed) + index * 0.17) % 1;
|
||||
const x = segment.source.x + (segment.target.x - segment.source.x) * t;
|
||||
const y = segment.source.y + (segment.target.y - segment.source.y) * t;
|
||||
const glow = context.createRadialGradient(x, y, 0, x, y, GRAPH_THEME.effects.pathPulse.radius);
|
||||
glow.addColorStop(0, withAlpha(GRAPH_THEME.palette.accent.path, GRAPH_THEME.effects.pathPulse.glowAlpha));
|
||||
glow.addColorStop(1, "rgba(0,0,0,0)");
|
||||
context.fillStyle = glow;
|
||||
context.beginPath();
|
||||
context.arc(x, y, GRAPH_THEME.effects.pathPulse.radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
import Graph from "graphology";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import {
|
||||
clamp,
|
||||
blendHex,
|
||||
GRAPH_THEME,
|
||||
type GraphArrowVisibilityPolicy,
|
||||
type GraphBadgeKind,
|
||||
type GraphEdgeVariant,
|
||||
type GraphEdgeVisualState,
|
||||
type GraphLabelVisibilityPolicy,
|
||||
type GraphNodeShapeVariant,
|
||||
type GraphNodeVisualState,
|
||||
type GraphTheme,
|
||||
type GraphZoomTier,
|
||||
withAlpha,
|
||||
zoomTierAtLeast,
|
||||
} from "./graphTheme";
|
||||
import type { GraphInteractionState, GraphViewMode } from "./types";
|
||||
|
||||
const MAX_FOCUS_NEIGHBORS = GRAPH_THEME.focus.maxNeighbors;
|
||||
const FOCUS_RING_CAPACITY = GRAPH_THEME.focus.ringCapacity;
|
||||
const FOCUS_RING_GAP = GRAPH_THEME.focus.ringGap;
|
||||
const FOCUS_PRIMARY_LABELS = GRAPH_THEME.focus.primaryLabels;
|
||||
|
||||
function getOverviewPresenceBoost(cameraRatio: number) {
|
||||
return clamp(0, Math.log2(Math.max(cameraRatio, 1)) / 1.85, 1);
|
||||
}
|
||||
|
||||
export type GraphSigmaEdgeType = "line" | "arrow" | "curve" | "curvedArrow";
|
||||
|
||||
export type ResolvedNodeStyle = {
|
||||
color: string;
|
||||
shellColor: string;
|
||||
coreScale: number;
|
||||
size: number;
|
||||
forceLabel: boolean;
|
||||
label: string;
|
||||
zIndex: number;
|
||||
hidden: boolean;
|
||||
borderColor: string;
|
||||
borderSize: number;
|
||||
nodeVariant: GraphNodeShapeVariant;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeCount?: number;
|
||||
showBadge: boolean;
|
||||
showRing: boolean;
|
||||
ringColor?: string;
|
||||
ringSize: number;
|
||||
showHalo: boolean;
|
||||
haloColor: string;
|
||||
};
|
||||
|
||||
export type ResolvedEdgeStyle = {
|
||||
hidden: boolean;
|
||||
type?: GraphSigmaEdgeType;
|
||||
color?: string;
|
||||
size?: number;
|
||||
zIndex: number;
|
||||
edgeVariant: GraphEdgeVariant;
|
||||
arrowVisibilityPolicy: GraphArrowVisibilityPolicy;
|
||||
curveStrength: number;
|
||||
curvature: number;
|
||||
};
|
||||
|
||||
function forEachDirectedEdgeBetween(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
source: string,
|
||||
target: string,
|
||||
callback: (edgeId: string, attrs: EdgeAttributes) => void,
|
||||
) {
|
||||
graphRef.forEachDirectedEdge(source, target, (edgeId, attrs) => {
|
||||
callback(String(edgeId), attrs as EdgeAttributes);
|
||||
});
|
||||
}
|
||||
|
||||
function collectDirectedEdgeIdsBetween(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
source: string,
|
||||
target: string,
|
||||
): string[] {
|
||||
const edgeIds: string[] = [];
|
||||
forEachDirectedEdgeBetween(graphRef, source, target, (edgeId) => {
|
||||
edgeIds.push(edgeId);
|
||||
});
|
||||
return edgeIds;
|
||||
}
|
||||
|
||||
export function buildPathEdgeSet(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
path: string[],
|
||||
pathEdgeIds: string[] = [],
|
||||
): Set<string> {
|
||||
if (pathEdgeIds.length > 0) {
|
||||
return new Set<string>(pathEdgeIds.filter((edgeId) => graphRef.hasEdge(edgeId)));
|
||||
}
|
||||
|
||||
const edgeIds = new Set<string>();
|
||||
for (let index = 0; index < path.length - 1; index += 1) {
|
||||
collectDirectedEdgeIdsBetween(graphRef, path[index], path[index + 1]).forEach((edgeId) => edgeIds.add(edgeId));
|
||||
}
|
||||
return edgeIds;
|
||||
}
|
||||
|
||||
export function buildEdgeEndpointSet(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
...edgeIds: Array<string | null | undefined>
|
||||
): Set<string> {
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
edgeIds.forEach((edgeId) => {
|
||||
if (!edgeId || !graphRef.hasEdge(edgeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [source, target] = graphRef.extremities(edgeId);
|
||||
nodeIds.add(source);
|
||||
nodeIds.add(target);
|
||||
});
|
||||
|
||||
return nodeIds;
|
||||
}
|
||||
|
||||
function collectFocusEdgeIds(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
nodeIds: Set<string>,
|
||||
): Set<string> {
|
||||
const edgeIds = new Set<string>();
|
||||
const ids = Array.from(nodeIds);
|
||||
|
||||
for (let sourceIndex = 0; sourceIndex < ids.length; sourceIndex += 1) {
|
||||
const source = ids[sourceIndex];
|
||||
|
||||
for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) {
|
||||
const target = ids[targetIndex];
|
||||
if (source === target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
collectDirectedEdgeIdsBetween(graphRef, source, target).forEach((edgeId) => edgeIds.add(edgeId));
|
||||
}
|
||||
}
|
||||
|
||||
return edgeIds;
|
||||
}
|
||||
|
||||
function collectImpactedNodeIds(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
interactionState: GraphInteractionState | null,
|
||||
): Set<string> {
|
||||
if (!interactionState) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const impacted = new Set<string>(interactionState.activePath);
|
||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||
buildFocusSet(primaryNodeId).forEach((nodeId) => impacted.add(nodeId));
|
||||
}
|
||||
|
||||
buildEdgeEndpointSet(graphRef, interactionState.selectedEdgeId)
|
||||
.forEach((nodeId) => impacted.add(nodeId));
|
||||
|
||||
return impacted;
|
||||
}
|
||||
|
||||
function collectImpactedEdgeKeys(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
interactionState: GraphInteractionState | null,
|
||||
): Set<string> {
|
||||
if (!interactionState) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const impacted = new Set<string>(buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds));
|
||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||
collectFocusEdgeIds(graphRef, buildFocusSet(primaryNodeId)).forEach((edgeId) => impacted.add(edgeId));
|
||||
}
|
||||
|
||||
if (interactionState.selectedEdgeId) {
|
||||
impacted.add(interactionState.selectedEdgeId);
|
||||
}
|
||||
|
||||
return impacted;
|
||||
}
|
||||
|
||||
function resolveDisplayEdgeIds(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
stableEdgeIds: Set<string>,
|
||||
): string[] {
|
||||
return Array.from(stableEdgeIds).filter((edgeId) => graphRef.hasEdge(edgeId));
|
||||
}
|
||||
|
||||
export function collectInteractionRefreshTargets(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
previousState: GraphInteractionState | null,
|
||||
nextState: GraphInteractionState,
|
||||
): { nodes: string[]; edges: string[] } {
|
||||
const nodeIds = new Set<string>();
|
||||
const edgeKeys = new Set<string>();
|
||||
|
||||
collectImpactedNodeIds(graphRef, previousState).forEach((nodeId) => nodeIds.add(nodeId));
|
||||
collectImpactedNodeIds(graphRef, nextState).forEach((nodeId) => nodeIds.add(nodeId));
|
||||
collectImpactedEdgeKeys(graphRef, previousState).forEach((edgeId) => edgeKeys.add(edgeId));
|
||||
collectImpactedEdgeKeys(graphRef, nextState).forEach((edgeId) => edgeKeys.add(edgeId));
|
||||
|
||||
return {
|
||||
nodes: Array.from(nodeIds).filter((nodeId) => graphRef.hasNode(nodeId)),
|
||||
edges: resolveDisplayEdgeIds(graphRef, edgeKeys),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEdgeWeightBetween(source: string, target: string): number {
|
||||
let weight = 0;
|
||||
|
||||
forEachDirectedEdgeBetween(graph, source, target, (_edgeId, attrs) => {
|
||||
weight = Math.max(weight, Number(attrs?.weight ?? 0));
|
||||
});
|
||||
|
||||
forEachDirectedEdgeBetween(graph, target, source, (_edgeId, attrs) => {
|
||||
weight = Math.max(weight, Number(attrs?.weight ?? 0));
|
||||
});
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
export function rankNeighbors(nodeId: string): string[] {
|
||||
return graph
|
||||
.neighbors(nodeId)
|
||||
.map((neighborId) => ({
|
||||
id: neighborId,
|
||||
weight: getEdgeWeightBetween(nodeId, neighborId),
|
||||
degree: graph.degree(neighborId),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (right.weight !== left.weight) {
|
||||
return right.weight - left.weight;
|
||||
}
|
||||
if (right.degree !== left.degree) {
|
||||
return right.degree - left.degree;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
})
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
export function buildFocusSet(nodeId: string): Set<string> {
|
||||
const ranked = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
|
||||
return new Set<string>([nodeId, ...ranked]);
|
||||
}
|
||||
|
||||
export function isEdgeInteractable(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
interactionState: GraphInteractionState,
|
||||
edgeId: string,
|
||||
source: string,
|
||||
target: string,
|
||||
attrs: EdgeAttributes,
|
||||
): boolean {
|
||||
const pathEdgeIds = buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds);
|
||||
if (pathEdgeIds.has(edgeId) || interactionState.selectedEdgeId === edgeId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const primaryNodeId = interactionState.selectedNodeId;
|
||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||
if (source === primaryNodeId || target === primaryNodeId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const focusIds = buildFocusSet(primaryNodeId);
|
||||
return focusIds.has(source) && focusIds.has(target);
|
||||
}
|
||||
|
||||
if (interactionState.zoomTier !== "inspection") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Number(attrs.visualPriority ?? 0) >= GRAPH_THEME.zoomTiers[interactionState.zoomTier].edgePriorityThreshold;
|
||||
}
|
||||
|
||||
function resolveNodeColor(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
cameraRatio: number,
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||
const overviewTint = state === "neighbor"
|
||||
? theme.palette.overview.nodeTintMix + 0.09
|
||||
: theme.palette.overview.nodeTintMix;
|
||||
const overviewCore = blendHex(
|
||||
theme.palette.overview.nodeCore,
|
||||
semanticColor,
|
||||
Math.min(0.74, theme.palette.overview.nodeCoreMix + overviewTint),
|
||||
);
|
||||
|
||||
switch (theme.nodes.states[state].color) {
|
||||
case "selected":
|
||||
return theme.palette.accent.selected;
|
||||
case "hovered":
|
||||
return theme.palette.accent.hovered;
|
||||
case "path":
|
||||
return theme.palette.accent.path;
|
||||
case "muted":
|
||||
return zoomTier === "overview"
|
||||
? withAlpha(theme.palette.overview.nodeMuted, 0.42)
|
||||
: String(attrs.mutedColor || withAlpha(semanticColor, theme.nodes.mutedAlpha));
|
||||
case "base":
|
||||
default:
|
||||
if (zoomTier === "overview") {
|
||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||
const boostedCore = blendHex(overviewCore, semanticColor, 0.3 + 0.26 * presenceBoost);
|
||||
return withAlpha(boostedCore, Math.min(0.98, theme.palette.overview.nodeCoreAlpha + presenceBoost * 0.18));
|
||||
}
|
||||
return semanticColor;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeShellColor(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
cameraRatio: number,
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||
const overviewShell = blendHex(
|
||||
theme.palette.overview.nodeBase,
|
||||
semanticColor,
|
||||
(state === "neighbor" ? 0.02 : 0.012) + presenceBoost * 0.024,
|
||||
);
|
||||
|
||||
if (zoomTier !== "overview") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, semanticColor, 0.26), 0.95);
|
||||
}
|
||||
|
||||
if (state === "selected") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.selected, 0.05), 0.98);
|
||||
}
|
||||
|
||||
if (state === "hovered") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.hovered, 0.06), 0.98);
|
||||
}
|
||||
|
||||
if (state === "path") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.path, 0.06), 0.97);
|
||||
}
|
||||
|
||||
if (state === "muted" || state === "inactive") {
|
||||
return withAlpha(theme.palette.overview.nodeMuted, 0.22);
|
||||
}
|
||||
|
||||
return withAlpha(overviewShell, theme.palette.overview.nodeShellAlpha);
|
||||
}
|
||||
|
||||
function resolveNodeCoreScale(
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
cameraRatio: number,
|
||||
) {
|
||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||
if (zoomTier === "overview") {
|
||||
switch (state) {
|
||||
case "selected":
|
||||
return 0.34 + presenceBoost * 0.08;
|
||||
case "hovered":
|
||||
return 0.32 + presenceBoost * 0.08;
|
||||
case "path":
|
||||
return 0.28 + presenceBoost * 0.07;
|
||||
case "neighbor":
|
||||
return 0.2 + presenceBoost * 0.06;
|
||||
case "muted":
|
||||
case "inactive":
|
||||
return 0.08 + presenceBoost * 0.03;
|
||||
case "default":
|
||||
default:
|
||||
return 0.16 + presenceBoost * 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case "selected":
|
||||
return 0.52;
|
||||
case "hovered":
|
||||
return 0.48;
|
||||
case "path":
|
||||
return 0.44;
|
||||
case "neighbor":
|
||||
return 0.3;
|
||||
case "muted":
|
||||
case "inactive":
|
||||
return 0.1;
|
||||
case "default":
|
||||
default:
|
||||
return zoomTier === "structure" ? 0.22 : 0.28;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEdgeColor(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphEdgeVisualState,
|
||||
attrs: EdgeAttributes,
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const defaultInspectionColor = zoomTier === "overview"
|
||||
? theme.palette.overview.edgeInspection
|
||||
: theme.palette.muted.edgeInspection;
|
||||
const baseColor = String(attrs.baseColor || fallbackColor || defaultInspectionColor);
|
||||
|
||||
switch (theme.edges.states[state].color) {
|
||||
case "hover":
|
||||
return theme.palette.accent.hovered;
|
||||
case "path":
|
||||
return theme.palette.accent.path;
|
||||
case "backbone":
|
||||
return zoomTier === "overview"
|
||||
? theme.palette.overview.edgeBackbone
|
||||
: theme.palette.muted.edgeFocus;
|
||||
case "focus":
|
||||
return theme.palette.muted.edgeFocus;
|
||||
case "overview":
|
||||
return theme.palette.muted.edgeOverview;
|
||||
case "structure":
|
||||
return zoomTier === "overview"
|
||||
? theme.palette.overview.edgeStructure
|
||||
: theme.palette.muted.edgeStructure;
|
||||
case "inspection":
|
||||
return zoomTier === "overview"
|
||||
? theme.palette.overview.edgeInspection
|
||||
: theme.palette.muted.edgeInspection;
|
||||
case "muted":
|
||||
return zoomTier === "overview"
|
||||
? theme.palette.overview.edgeStructure
|
||||
: String(attrs.mutedColor || theme.palette.muted.edgeOverview);
|
||||
default:
|
||||
return baseColor;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeRingColor(
|
||||
theme: GraphTheme,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
) {
|
||||
if (state === "selected") {
|
||||
return attrs.ringColor || theme.nodes.selectedRing.color;
|
||||
}
|
||||
if (state === "hovered") {
|
||||
return theme.palette.accent.hovered;
|
||||
}
|
||||
if (state === "path") {
|
||||
return theme.palette.accent.path;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveNodeRingSize(
|
||||
theme: GraphTheme,
|
||||
state: GraphNodeVisualState,
|
||||
zoomTier: GraphZoomTier,
|
||||
) {
|
||||
if (state === "selected" && zoomTierAtLeast(zoomTier, theme.nodes.selectedRing.visibleFrom)) {
|
||||
return theme.nodes.selectedRing.nativeSize;
|
||||
}
|
||||
if (state === "hovered") {
|
||||
return Math.max(1.45, theme.nodes.selectedRing.nativeSize - 0.35);
|
||||
}
|
||||
if (state === "path") {
|
||||
return Math.max(1.2, theme.nodes.selectedRing.nativeSize - 0.55);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function resolveNodeVisualState(
|
||||
nodeId: string,
|
||||
zoomTier: GraphZoomTier,
|
||||
hoveredNodeId: string | null,
|
||||
selectedNodeId: string,
|
||||
selectedEdgeId: string,
|
||||
focusIds: Set<string>,
|
||||
edgeEndpointIds: Set<string>,
|
||||
pathNodeIds: Set<string>,
|
||||
): GraphNodeVisualState {
|
||||
if (hoveredNodeId && nodeId === hoveredNodeId) {
|
||||
return "hovered";
|
||||
}
|
||||
if (selectedNodeId && nodeId === selectedNodeId) {
|
||||
return "selected";
|
||||
}
|
||||
if (pathNodeIds.has(nodeId)) {
|
||||
return "path";
|
||||
}
|
||||
if (focusIds.has(nodeId)) {
|
||||
return "neighbor";
|
||||
}
|
||||
if (edgeEndpointIds.has(nodeId)) {
|
||||
return "neighbor";
|
||||
}
|
||||
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathNodeIds.size > 0) {
|
||||
if (zoomTier === "overview") {
|
||||
return "default";
|
||||
}
|
||||
return "muted";
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function resolveEdgeVisualState(
|
||||
edgeId: string,
|
||||
source: string,
|
||||
target: string,
|
||||
zoomTier: GraphZoomTier,
|
||||
hoveredNodeId: string | null,
|
||||
selectedNodeId: string,
|
||||
selectedEdgeId: string,
|
||||
focusIds: Set<string>,
|
||||
pathEdgeIds: Set<string>,
|
||||
overviewBackboneEdgeIds: Set<string>,
|
||||
): GraphEdgeVisualState {
|
||||
const primaryNodeId = hoveredNodeId || selectedNodeId;
|
||||
|
||||
if (pathEdgeIds.has(edgeId)) {
|
||||
return "path";
|
||||
}
|
||||
|
||||
if (selectedEdgeId && edgeId === selectedEdgeId) {
|
||||
return "selected";
|
||||
}
|
||||
|
||||
if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
|
||||
return hoveredNodeId ? "hovered" : "selected";
|
||||
}
|
||||
|
||||
if (zoomTier !== "overview" && focusIds.has(source) && focusIds.has(target)) {
|
||||
return "neighbor";
|
||||
}
|
||||
|
||||
if (zoomTier === "overview" && overviewBackboneEdgeIds.has(edgeId)) {
|
||||
return "backbone";
|
||||
}
|
||||
|
||||
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathEdgeIds.size > 0) {
|
||||
return "muted";
|
||||
}
|
||||
|
||||
if (zoomTier === "overview") {
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function resolveNodeVariant(state: GraphNodeVisualState, attrs: NodeAttributes): GraphNodeShapeVariant {
|
||||
if (state === "selected") {
|
||||
return "selected";
|
||||
}
|
||||
|
||||
return attrs.nodeShapeVariant || attrs.nodeVariant || "default";
|
||||
}
|
||||
|
||||
export function resolveEdgeVariant(state: GraphEdgeVisualState, attrs: EdgeAttributes): GraphEdgeVariant {
|
||||
if (state === "path") {
|
||||
return "pathSignal";
|
||||
}
|
||||
|
||||
if ((attrs.parallelCount ?? 1) > 1) {
|
||||
return "parallelCurve";
|
||||
}
|
||||
|
||||
if (attrs.edgeVariant) {
|
||||
return attrs.edgeVariant;
|
||||
}
|
||||
|
||||
if (attrs.isBidirectional) {
|
||||
return "bidirectionalCurve";
|
||||
}
|
||||
|
||||
if (attrs.arrowVisibilityPolicy === "contextual") {
|
||||
return "directional";
|
||||
}
|
||||
|
||||
return "line";
|
||||
}
|
||||
|
||||
export function shouldForceNodeLabel(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
labelPriority: number,
|
||||
): boolean {
|
||||
const tierConfig = theme.zoomTiers[zoomTier];
|
||||
const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
|
||||
const policy = attrs.labelVisibilityPolicy || "priority";
|
||||
|
||||
if (forceVisibleState || theme.nodes.states[state].forceLabel) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (policy as GraphLabelVisibilityPolicy) {
|
||||
case "always":
|
||||
return true;
|
||||
case "local":
|
||||
return zoomTier !== "overview" && state !== "default" && state !== "muted" && state !== "inactive";
|
||||
case "priority":
|
||||
return labelPriority >= tierConfig.labelThreshold;
|
||||
case "none":
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeBorderColor(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
variant: GraphNodeShapeVariant,
|
||||
attrs: NodeAttributes,
|
||||
baseColor: string,
|
||||
) {
|
||||
if (state === "selected" || variant === "selected") {
|
||||
return attrs.ringColor || theme.nodes.selectedRing.color;
|
||||
}
|
||||
if (state === "hovered") {
|
||||
return theme.palette.accent.hovered;
|
||||
}
|
||||
if (state === "path") {
|
||||
return theme.palette.accent.path;
|
||||
}
|
||||
if (state === "muted" || state === "inactive") {
|
||||
return withAlpha(
|
||||
attrs.strokeColor || attrs.borderColor || theme.palette.overview.nodeBorder || theme.palette.background.nodeBorder,
|
||||
zoomTier === "overview" ? 0.26 : 0.7,
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "temporal") {
|
||||
return theme.palette.accent.temporal;
|
||||
}
|
||||
if (variant === "provenance") {
|
||||
return theme.palette.accent.provenance;
|
||||
}
|
||||
if (variant === "inferred") {
|
||||
return theme.palette.accent.inferred;
|
||||
}
|
||||
|
||||
if (zoomTier === "overview") {
|
||||
return withAlpha(
|
||||
blendHex(theme.palette.overview.nodeBorder, baseColor, state === "neighbor" ? 0.08 : 0.03),
|
||||
state === "neighbor" ? 0.24 : 0.06,
|
||||
);
|
||||
}
|
||||
|
||||
return attrs.strokeColor || attrs.borderColor || theme.palette.background.nodeBorder || baseColor;
|
||||
}
|
||||
|
||||
export function resolveNodeElementStyle(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
label: string,
|
||||
cameraRatio = 1,
|
||||
): ResolvedNodeStyle {
|
||||
const tierConfig = theme.zoomTiers[zoomTier];
|
||||
const stateConfig = theme.nodes.states[state];
|
||||
const nodeVariant = resolveNodeVariant(state, attrs);
|
||||
const variantConfig = theme.nodes.variants[nodeVariant];
|
||||
const baseSize = Number(attrs.baseSize || attrs.size || 4);
|
||||
const labelPriority = Number(attrs.labelPriority ?? 0);
|
||||
const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
||||
const shellColor = resolveNodeShellColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
||||
const sizeMultiplier = (state === "default" ? tierConfig.nodeScale : stateConfig.sizeMultiplier) * variantConfig.sizeMultiplier;
|
||||
const overviewPresence = zoomTier === "overview" ? 1 + getOverviewPresenceBoost(cameraRatio) * 1.2 : 1;
|
||||
const forceLabel = shouldForceNodeLabel(theme, zoomTier, state, attrs, labelPriority);
|
||||
const badgeKind = attrs.badgeKind || variantConfig.badgeKind;
|
||||
const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
|
||||
const showBadge = Boolean(
|
||||
badgeKind
|
||||
&& (forceVisibleState || (tierConfig.showBadges && zoomTierAtLeast(zoomTier, variantConfig.badgeVisibleFrom)))
|
||||
&& state !== "muted"
|
||||
&& state !== "inactive",
|
||||
);
|
||||
const ringSize = resolveNodeRingSize(theme, state, zoomTier);
|
||||
const ringColor = resolveNodeRingColor(theme, state, attrs);
|
||||
const showRing = ringSize > 0;
|
||||
const showHalo = state === "hovered" || state === "selected" || state === "path";
|
||||
const strokeBase = state === "muted" || state === "inactive"
|
||||
? theme.nodes.strokeHierarchy[zoomTier].muted
|
||||
: forceVisibleState
|
||||
? theme.nodes.strokeHierarchy[zoomTier].emphasis
|
||||
: theme.nodes.strokeHierarchy[zoomTier].base;
|
||||
|
||||
return {
|
||||
color,
|
||||
shellColor,
|
||||
coreScale: resolveNodeCoreScale(zoomTier, state, cameraRatio),
|
||||
size: Math.max(baseSize * sizeMultiplier * overviewPresence, stateConfig.minSize),
|
||||
forceLabel,
|
||||
label: forceLabel ? label : "",
|
||||
zIndex: forceLabel && stateConfig.zIndex === 0 ? 1 : stateConfig.zIndex,
|
||||
hidden: false,
|
||||
borderColor: resolveNodeBorderColor(theme, zoomTier, state, nodeVariant, attrs, color),
|
||||
borderSize: Math.max(
|
||||
0.4,
|
||||
Number(attrs.borderSize ?? 0.85) + strokeBase + stateConfig.borderBoost + variantConfig.borderBoost - 0.8,
|
||||
),
|
||||
nodeVariant,
|
||||
badgeKind,
|
||||
badgeCount: attrs.badgeCount,
|
||||
showBadge,
|
||||
showRing,
|
||||
ringColor,
|
||||
ringSize,
|
||||
showHalo,
|
||||
haloColor: attrs.haloColor || attrs.glowColor || withAlpha(color, theme.overlays.hoverGlowAlpha + variantConfig.haloBoost),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStraightEdgeType(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphEdgeVisualState,
|
||||
variant: GraphEdgeVariant,
|
||||
attrs: EdgeAttributes,
|
||||
): "line" | "arrow" {
|
||||
const variantConfig = theme.edges.variants[variant];
|
||||
|
||||
if (theme.edges.states[state].forceArrow || variantConfig.arrowPolicy === "always") {
|
||||
return "arrow";
|
||||
}
|
||||
|
||||
if (variantConfig.arrowPolicy === "contextual" && theme.zoomTiers[zoomTier].showContextualArrows) {
|
||||
return "arrow";
|
||||
}
|
||||
|
||||
if (state !== "default") {
|
||||
return (attrs.type as "line" | "arrow" | undefined) || variantConfig.baseType;
|
||||
}
|
||||
|
||||
return Number(attrs.visualPriority ?? 0) >= theme.zoomTiers[zoomTier].arrowPriorityThreshold && theme.zoomTiers[zoomTier].showContextualArrows
|
||||
? "arrow"
|
||||
: "line";
|
||||
}
|
||||
|
||||
function resolveEdgeCurvature(
|
||||
theme: GraphTheme,
|
||||
state: GraphEdgeVisualState,
|
||||
edgeVariant: GraphEdgeVariant,
|
||||
attrs: EdgeAttributes,
|
||||
sourceId: string | undefined,
|
||||
targetId: string | undefined,
|
||||
) {
|
||||
const variantConfig = theme.edges.variants[edgeVariant];
|
||||
const baseCurvature = edgeVariant === "line" && state === "selected"
|
||||
? Math.max(variantConfig.curveStrength, 0.14)
|
||||
: variantConfig.curveStrength;
|
||||
|
||||
if (baseCurvature === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof attrs.parallelCount === "number" && attrs.parallelCount > 1 && typeof attrs.parallelIndex === "number") {
|
||||
const center = (attrs.parallelCount - 1) / 2;
|
||||
return (attrs.parallelIndex - center) * baseCurvature;
|
||||
}
|
||||
|
||||
if ((edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve" || attrs.isBidirectional) && sourceId && targetId) {
|
||||
return sourceId.localeCompare(targetId) <= 0 ? baseCurvature : -baseCurvature;
|
||||
}
|
||||
|
||||
return baseCurvature;
|
||||
}
|
||||
|
||||
export function resolveEdgeElementStyle(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphEdgeVisualState,
|
||||
attrs: EdgeAttributes,
|
||||
sourceId?: string,
|
||||
targetId?: string,
|
||||
): ResolvedEdgeStyle {
|
||||
const tierConfig = theme.zoomTiers[zoomTier];
|
||||
const stateConfig = theme.edges.states[state];
|
||||
const edgeVariant = resolveEdgeVariant(state, attrs);
|
||||
const variantConfig = theme.edges.variants[edgeVariant];
|
||||
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
|
||||
const visualPriority = Number(attrs.visualPriority ?? 0);
|
||||
const belowPriorityThreshold = state === "default"
|
||||
&& visualPriority < tierConfig.edgePriorityThreshold
|
||||
&& edgeVariant === "line";
|
||||
|
||||
if (stateConfig.hide || belowPriorityThreshold) {
|
||||
return {
|
||||
hidden: true,
|
||||
zIndex: 0,
|
||||
edgeVariant,
|
||||
arrowVisibilityPolicy: variantConfig.arrowPolicy,
|
||||
curveStrength: variantConfig.curveStrength,
|
||||
curvature: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sizeMultiplier = (state === "default" ? tierConfig.edgeSizeScale : stateConfig.sizeMultiplier) * variantConfig.sizeMultiplier;
|
||||
const straightType = resolveStraightEdgeType(theme, zoomTier, state, edgeVariant, attrs);
|
||||
const useCurvedRenderer = tierConfig.showCurves
|
||||
&& zoomTier !== "overview"
|
||||
&& (
|
||||
edgeVariant === "pathSignal"
|
||||
|| state === "selected"
|
||||
|| ((state === "neighbor" || state === "hovered") && (edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve"))
|
||||
);
|
||||
const curvature = useCurvedRenderer
|
||||
? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
hidden: false,
|
||||
type: useCurvedRenderer
|
||||
? (straightType === "arrow" ? "curvedArrow" : "curve")
|
||||
: straightType,
|
||||
color: resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color),
|
||||
size: Math.max(baseSize * sizeMultiplier, stateConfig.minSize),
|
||||
zIndex: stateConfig.zIndex,
|
||||
edgeVariant,
|
||||
arrowVisibilityPolicy: variantConfig.arrowPolicy,
|
||||
curveStrength: variantConfig.curveStrength,
|
||||
curvature,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFocusedGraph(
|
||||
nodeId: string,
|
||||
activePath: string[],
|
||||
activePathEdgeIds: string[] = [],
|
||||
): Graph<NodeAttributes, EdgeAttributes> {
|
||||
const focused = new Graph<NodeAttributes, EdgeAttributes>({
|
||||
type: "directed",
|
||||
multi: true,
|
||||
allowSelfLoops: false,
|
||||
});
|
||||
|
||||
const rankedNeighbors = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
|
||||
const focusIds = new Set<string>([nodeId, ...rankedNeighbors]);
|
||||
const labelledNeighborIds = new Set(rankedNeighbors.slice(0, FOCUS_PRIMARY_LABELS));
|
||||
const pathNodeIds = new Set(activePath);
|
||||
const pathEdgeIds = buildPathEdgeSet(graph, activePath, activePathEdgeIds);
|
||||
|
||||
const addNode = (id: string, attrs: NodeAttributes) => {
|
||||
if (!focused.hasNode(id)) {
|
||||
focused.addNode(id, attrs);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedAttrs = graph.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
const selectedState = resolveNodeElementStyle(GRAPH_THEME, "inspection", "selected", selectedAttrs, selectedAttrs.label);
|
||||
addNode(nodeId, {
|
||||
...selectedAttrs,
|
||||
x: 0,
|
||||
y: 0,
|
||||
color: selectedState.color,
|
||||
size: Math.max(selectedState.size, 22),
|
||||
baseColor: selectedState.color,
|
||||
baseSize: Math.max(selectedState.size, 22),
|
||||
label: selectedState.label,
|
||||
});
|
||||
|
||||
rankedNeighbors.forEach((neighborId, index) => {
|
||||
const baseAttrs = graph.getNodeAttributes(neighborId) as NodeAttributes;
|
||||
const ring = Math.floor(index / FOCUS_RING_CAPACITY);
|
||||
const ringIndex = index % FOCUS_RING_CAPACITY;
|
||||
const itemsInRing = Math.min(
|
||||
FOCUS_RING_CAPACITY,
|
||||
rankedNeighbors.length - ring * FOCUS_RING_CAPACITY,
|
||||
);
|
||||
const radius = FOCUS_RING_GAP * (ring + 1);
|
||||
const angle = (Math.PI * 2 * ringIndex) / itemsInRing - Math.PI / 2;
|
||||
const visualState: GraphNodeVisualState = pathNodeIds.has(neighborId)
|
||||
? "path"
|
||||
: labelledNeighborIds.has(neighborId)
|
||||
? "neighbor"
|
||||
: "default";
|
||||
const style = resolveNodeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
visualState,
|
||||
{
|
||||
...baseAttrs,
|
||||
labelPriority: labelledNeighborIds.has(neighborId) || pathNodeIds.has(neighborId)
|
||||
? Math.max(Number(baseAttrs.labelPriority ?? 0), 1)
|
||||
: 0,
|
||||
},
|
||||
baseAttrs.label,
|
||||
);
|
||||
|
||||
addNode(neighborId, {
|
||||
...baseAttrs,
|
||||
x: Math.cos(angle) * radius,
|
||||
y: Math.sin(angle) * radius,
|
||||
color: style.color,
|
||||
size: Math.max(style.size, 8.5),
|
||||
baseColor: style.color,
|
||||
baseSize: Math.max(style.size, 8.5),
|
||||
label: style.label,
|
||||
});
|
||||
});
|
||||
|
||||
for (const source of focusIds) {
|
||||
for (const target of focusIds) {
|
||||
if (source === target) {
|
||||
continue;
|
||||
}
|
||||
forEachDirectedEdgeBetween(graph, source, target, (edgeId, attrs) => {
|
||||
const state: GraphEdgeVisualState = pathEdgeIds.has(edgeId)
|
||||
? "path"
|
||||
: source === nodeId || target === nodeId
|
||||
? "selected"
|
||||
: "neighbor";
|
||||
const style = resolveEdgeElementStyle(GRAPH_THEME, "inspection", state, attrs, source, target);
|
||||
focused.mergeDirectedEdgeWithKey(edgeId, source, target, {
|
||||
...attrs,
|
||||
type: style.type,
|
||||
size: style.size,
|
||||
color: style.color,
|
||||
baseSize: style.size,
|
||||
baseColor: style.color,
|
||||
curvature: style.curvature,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return focused;
|
||||
}
|
||||
|
||||
export function resolveDisplayGraph(
|
||||
selectedNodeId: string,
|
||||
activePath: string[],
|
||||
activePathEdgeIds: string[],
|
||||
viewMode: GraphViewMode,
|
||||
) {
|
||||
const isFocusedView = viewMode === "focused" && Boolean(selectedNodeId) && graph.hasNode(selectedNodeId);
|
||||
return isFocusedView && selectedNodeId ? createFocusedGraph(selectedNodeId, activePath, activePathEdgeIds) : graph;
|
||||
}
|
||||
|
||||
export function createInteractionState(
|
||||
hoveredNodeId: string | null,
|
||||
selectedNodeId: string,
|
||||
selectedEdgeId: string,
|
||||
activePath: string[],
|
||||
activePathEdgeIds: string[],
|
||||
viewMode: GraphViewMode,
|
||||
zoomTier: GraphZoomTier,
|
||||
isLayoutRunning: boolean,
|
||||
): GraphInteractionState {
|
||||
return {
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
focusedNodeId: selectedNodeId,
|
||||
activePath,
|
||||
activePathEdgeIds,
|
||||
viewMode,
|
||||
zoomTier,
|
||||
isLayoutRunning,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
export type GraphZoomTier = "overview" | "structure" | "inspection";
|
||||
export type GraphNodeVisualState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphEdgeVisualState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphNodeShapeVariant = "default" | "temporal" | "inferred" | "provenance" | "selected";
|
||||
export type GraphEdgeVariant = "line" | "directional" | "bidirectionalCurve" | "parallelCurve" | "pathSignal";
|
||||
export type GraphArrowVisibilityPolicy = "hidden" | "contextual" | "always";
|
||||
export type GraphLabelVisibilityPolicy = "none" | "priority" | "local" | "always";
|
||||
export type GraphBadgeKind = "inferred" | "temporal" | "provenance";
|
||||
|
||||
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
|
||||
type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted";
|
||||
|
||||
export interface GraphTheme {
|
||||
palette: {
|
||||
semantic: string[];
|
||||
overview: {
|
||||
nodeBase: string;
|
||||
nodeCore: string;
|
||||
nodeMuted: string;
|
||||
nodeBorder: string;
|
||||
nodeTintMix: number;
|
||||
nodeCoreMix: number;
|
||||
nodeShellAlpha: number;
|
||||
nodeCoreAlpha: number;
|
||||
edgeBackbone: string;
|
||||
edgeStructure: string;
|
||||
edgeInspection: string;
|
||||
};
|
||||
accent: {
|
||||
selected: string;
|
||||
hovered: string;
|
||||
path: string;
|
||||
temporal: string;
|
||||
provenance: string;
|
||||
inferred: string;
|
||||
};
|
||||
muted: {
|
||||
fallback: string;
|
||||
nodeAlpha: number;
|
||||
edgeOverview: string;
|
||||
edgeStructure: string;
|
||||
edgeInspection: string;
|
||||
edgeFocus: string;
|
||||
};
|
||||
background: {
|
||||
canvas: string;
|
||||
shell: string;
|
||||
shellBorder: string;
|
||||
shellGlow: string;
|
||||
grid: string;
|
||||
vignette: string;
|
||||
nodeBorder: string;
|
||||
};
|
||||
};
|
||||
zoomTiers: Record<GraphZoomTier, {
|
||||
maxRatio: number;
|
||||
nodeScale: number;
|
||||
labelThreshold: number;
|
||||
labelBudget: number;
|
||||
edgePriorityThreshold: number;
|
||||
arrowPriorityThreshold: number;
|
||||
edgeSizeScale: number;
|
||||
showBadges: boolean;
|
||||
showCurves: boolean;
|
||||
showContextualArrows: boolean;
|
||||
}>;
|
||||
labels: {
|
||||
forceVisibleStates: readonly GraphNodeVisualState[];
|
||||
policies: Record<GraphLabelVisibilityPolicy, {
|
||||
minZoomTier: GraphZoomTier;
|
||||
}>;
|
||||
chip: {
|
||||
fontFamily: string;
|
||||
fontWeight: number;
|
||||
fontSize: number;
|
||||
maxFontSize: number;
|
||||
sizeScale: number;
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
radius: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
background: string;
|
||||
borderColor: string;
|
||||
borderAlpha: number;
|
||||
textColor: string;
|
||||
shadowColor: string;
|
||||
shadowAlpha: number;
|
||||
shadowBlur: number;
|
||||
};
|
||||
hoverCard: {
|
||||
fontFamily: string;
|
||||
titleWeight: number;
|
||||
titleSize: number;
|
||||
metaWeight: number;
|
||||
metaSize: number;
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
radius: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
metaGap: number;
|
||||
background: string;
|
||||
borderColor: string;
|
||||
borderAlpha: number;
|
||||
textColor: string;
|
||||
metaColor: string;
|
||||
shadowColor: string;
|
||||
shadowAlpha: number;
|
||||
shadowBlur: number;
|
||||
};
|
||||
};
|
||||
nodes: {
|
||||
backgroundScale: number;
|
||||
mutedAlpha: number;
|
||||
strokeHierarchy: Record<GraphZoomTier, {
|
||||
base: number;
|
||||
emphasis: number;
|
||||
muted: number;
|
||||
}>;
|
||||
states: Record<GraphNodeVisualState, {
|
||||
color: GraphNodeColorMode;
|
||||
sizeMultiplier: number;
|
||||
minSize: number;
|
||||
forceLabel: boolean;
|
||||
zIndex: number;
|
||||
borderBoost: number;
|
||||
}>;
|
||||
variants: Record<GraphNodeShapeVariant, {
|
||||
sizeMultiplier: number;
|
||||
borderBoost: number;
|
||||
haloBoost: number;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeVisibleFrom: GraphZoomTier;
|
||||
}>;
|
||||
selectedRing: {
|
||||
color: string;
|
||||
width: number;
|
||||
nativeSize: number;
|
||||
glowAlpha: number;
|
||||
visibleFrom: GraphZoomTier;
|
||||
};
|
||||
badges: Record<GraphBadgeKind, {
|
||||
color: string;
|
||||
label: string;
|
||||
}>;
|
||||
badge: {
|
||||
radius: number;
|
||||
offset: number;
|
||||
fontSize: number;
|
||||
textColor: string;
|
||||
background: string;
|
||||
stroke: string;
|
||||
glowAlpha: number;
|
||||
};
|
||||
};
|
||||
edges: {
|
||||
states: Record<GraphEdgeVisualState, {
|
||||
color: GraphEdgeColorMode;
|
||||
sizeMultiplier: number;
|
||||
minSize: number;
|
||||
zIndex: number;
|
||||
forceArrow: boolean;
|
||||
hide: boolean;
|
||||
}>;
|
||||
variants: Record<GraphEdgeVariant, {
|
||||
baseType: "line" | "arrow";
|
||||
arrowPolicy: GraphArrowVisibilityPolicy;
|
||||
curveStrength: number;
|
||||
sizeMultiplier: number;
|
||||
glowAlpha: number;
|
||||
}>;
|
||||
};
|
||||
overlays: {
|
||||
hoverGlowAlpha: number;
|
||||
pathGlowAlpha: number;
|
||||
glowRadiusMultiplier: number;
|
||||
minGlowRadius: number;
|
||||
pulseRadius: number;
|
||||
curveLineWidth: number;
|
||||
curveGlowWidth: number;
|
||||
badgeGlowRadius: number;
|
||||
};
|
||||
focus: {
|
||||
maxNeighbors: number;
|
||||
ringCapacity: number;
|
||||
ringGap: number;
|
||||
primaryLabels: number;
|
||||
};
|
||||
motion: {
|
||||
cameraMs: number;
|
||||
};
|
||||
effects: {
|
||||
pathPulse: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxSegments: number;
|
||||
speed: number;
|
||||
radius: number;
|
||||
glowAlpha: number;
|
||||
};
|
||||
pathFlow: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxSegments: number;
|
||||
speed: number;
|
||||
spacing: number;
|
||||
opacity: number;
|
||||
radius: number;
|
||||
};
|
||||
lens: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
radius: number;
|
||||
feather: number;
|
||||
glowAlpha: number;
|
||||
edgeAlpha: number;
|
||||
edgeLineWidth: number;
|
||||
};
|
||||
temporalEmphasis: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxHighlights: number;
|
||||
radiusMultiplier: number;
|
||||
glowAlpha: number;
|
||||
};
|
||||
semanticRegions: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxRegions: number;
|
||||
minVisibleSamples: number;
|
||||
fogResolutionScale: number;
|
||||
splatRadius: number;
|
||||
blurPasses: number;
|
||||
densityThreshold: number;
|
||||
contourThreshold: number;
|
||||
minMaskPixels: number;
|
||||
minOccupancyRatio: number;
|
||||
dominantMassRatio: number;
|
||||
outerContourMinMaskPixels: number;
|
||||
fogAlpha: number;
|
||||
innerContourAlpha: number;
|
||||
outerContourAlpha: number;
|
||||
};
|
||||
contours: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxContours: number;
|
||||
baseRadius: number;
|
||||
glowAlpha: number;
|
||||
};
|
||||
legend: {
|
||||
maxGroups: number;
|
||||
};
|
||||
diagnostics: {
|
||||
enabledInDev: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const GRAPH_THEME: GraphTheme = {
|
||||
palette: {
|
||||
semantic: [
|
||||
"#3E79F2",
|
||||
"#149287",
|
||||
"#2F9F61",
|
||||
"#555FD6",
|
||||
"#8A56D8",
|
||||
"#B65473",
|
||||
"#C9922E",
|
||||
],
|
||||
overview: {
|
||||
nodeBase: "#0B1320",
|
||||
nodeCore: "#435D7A",
|
||||
nodeMuted: "#121927",
|
||||
nodeBorder: "#64758C",
|
||||
nodeTintMix: 0.03,
|
||||
nodeCoreMix: 0.52,
|
||||
nodeShellAlpha: 0.97,
|
||||
nodeCoreAlpha: 1,
|
||||
edgeBackbone: "rgba(83, 111, 148, 0.04)",
|
||||
edgeStructure: "rgba(72, 90, 118, 0.009)",
|
||||
edgeInspection: "rgba(98, 120, 148, 0.026)",
|
||||
},
|
||||
accent: {
|
||||
selected: "#F2D288",
|
||||
hovered: "#8FE7FF",
|
||||
path: "#D79056",
|
||||
temporal: "#49D7FF",
|
||||
provenance: "#C9A5FF",
|
||||
inferred: "#D07B4D",
|
||||
},
|
||||
muted: {
|
||||
fallback: "rgba(96, 112, 136, 0.1)",
|
||||
nodeAlpha: 0.085,
|
||||
edgeOverview: "rgba(82, 100, 124, 0.009)",
|
||||
edgeStructure: "rgba(92, 112, 138, 0.02)",
|
||||
edgeInspection: "rgba(124, 148, 176, 0.066)",
|
||||
edgeFocus: "rgba(160, 186, 218, 0.16)",
|
||||
},
|
||||
background: {
|
||||
canvas: "#07101A",
|
||||
shell: "rgba(8, 15, 26, 0.8)",
|
||||
shellBorder: "rgba(118, 162, 207, 0.14)",
|
||||
shellGlow: "rgba(48, 88, 140, 0.14)",
|
||||
grid: "rgba(92, 126, 170, 0.034)",
|
||||
vignette: "rgba(2, 5, 11, 0.84)",
|
||||
nodeBorder: "#0C1522",
|
||||
},
|
||||
},
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.66,
|
||||
labelThreshold: 0.985,
|
||||
labelBudget: 10,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.34,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 0.98,
|
||||
labelThreshold: 0.88,
|
||||
labelBudget: 36,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
},
|
||||
inspection: {
|
||||
maxRatio: 0.5,
|
||||
nodeScale: 1,
|
||||
labelThreshold: 0.7,
|
||||
labelBudget: 80,
|
||||
edgePriorityThreshold: 0,
|
||||
arrowPriorityThreshold: 0.58,
|
||||
edgeSizeScale: 1.04,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
|
||||
policies: {
|
||||
none: { minZoomTier: "inspection" },
|
||||
priority: { minZoomTier: "overview" },
|
||||
local: { minZoomTier: "structure" },
|
||||
always: { minZoomTier: "overview" },
|
||||
},
|
||||
chip: {
|
||||
fontFamily: "\"IBM Plex Sans\", Inter, system-ui, sans-serif",
|
||||
fontWeight: 500,
|
||||
fontSize: 10,
|
||||
maxFontSize: 11,
|
||||
sizeScale: 0.25,
|
||||
paddingX: 6,
|
||||
paddingY: 3,
|
||||
radius: 6,
|
||||
offsetX: 12,
|
||||
offsetY: 10,
|
||||
background: "rgba(8, 14, 24, 0.9)",
|
||||
borderColor: "rgba(154, 181, 212, 0.16)",
|
||||
borderAlpha: 0.28,
|
||||
textColor: "#EAF3FF",
|
||||
shadowColor: "rgba(0, 0, 0, 0.6)",
|
||||
shadowAlpha: 0.26,
|
||||
shadowBlur: 12,
|
||||
},
|
||||
hoverCard: {
|
||||
fontFamily: "\"IBM Plex Sans\", Inter, system-ui, sans-serif",
|
||||
titleWeight: 700,
|
||||
titleSize: 13,
|
||||
metaWeight: 500,
|
||||
metaSize: 10,
|
||||
paddingX: 10,
|
||||
paddingY: 7,
|
||||
radius: 12,
|
||||
offsetX: 16,
|
||||
offsetY: 16,
|
||||
metaGap: 5,
|
||||
background: "rgba(8, 14, 24, 0.94)",
|
||||
borderColor: "rgba(154, 181, 212, 0.18)",
|
||||
borderAlpha: 0.32,
|
||||
textColor: "#F6FBFF",
|
||||
metaColor: "rgba(184, 214, 255, 0.58)",
|
||||
shadowColor: "rgba(0, 0, 0, 0.62)",
|
||||
shadowAlpha: 0.34,
|
||||
shadowBlur: 15,
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
backgroundScale: 0.52,
|
||||
mutedAlpha: 0.08,
|
||||
strokeHierarchy: {
|
||||
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
|
||||
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
|
||||
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
||||
},
|
||||
states: {
|
||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.18, minSize: 12.5, forceLabel: true, zIndex: 4, borderBoost: 0.22 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.06, minSize: 10.5, forceLabel: true, zIndex: 3, borderBoost: 0.2 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.84, minSize: 4.8, forceLabel: true, zIndex: 2, borderBoost: -0.08 },
|
||||
path: { color: "path", sizeMultiplier: 1.01, minSize: 6.2, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
},
|
||||
variants: {
|
||||
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
|
||||
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "structure" },
|
||||
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "structure" },
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" },
|
||||
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
||||
},
|
||||
selectedRing: {
|
||||
color: "#E7C57C",
|
||||
width: 1.9,
|
||||
nativeSize: 2.2,
|
||||
glowAlpha: 0.2,
|
||||
visibleFrom: "overview",
|
||||
},
|
||||
badges: {
|
||||
inferred: { color: "#C98658", label: "I" },
|
||||
temporal: { color: "#52CDEF", label: "T" },
|
||||
provenance: { color: "#A289D0", label: "P" },
|
||||
},
|
||||
badge: {
|
||||
radius: 7,
|
||||
offset: 3,
|
||||
fontSize: 8,
|
||||
textColor: "#08111d",
|
||||
background: "rgba(8, 17, 29, 0.84)",
|
||||
stroke: "rgba(255,255,255,0.14)",
|
||||
glowAlpha: 0.24,
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
states: {
|
||||
default: { color: "structure", sizeMultiplier: 0.74, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 0.72, minSize: 0.18, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 0.92, minSize: 0.5, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.5, minSize: 1.8, zIndex: 4, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
||||
muted: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
||||
},
|
||||
variants: {
|
||||
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
|
||||
directional: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0, sizeMultiplier: 1.04, glowAlpha: 0.08 },
|
||||
bidirectionalCurve: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0.18, sizeMultiplier: 1.08, glowAlpha: 0.1 },
|
||||
parallelCurve: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0.24, sizeMultiplier: 1.1, glowAlpha: 0.12 },
|
||||
pathSignal: { baseType: "arrow", arrowPolicy: "always", curveStrength: 0.16, sizeMultiplier: 1.18, glowAlpha: 0.2 },
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
hoverGlowAlpha: 0.18,
|
||||
pathGlowAlpha: 0.16,
|
||||
glowRadiusMultiplier: 4.8,
|
||||
minGlowRadius: 16,
|
||||
pulseRadius: 11,
|
||||
curveLineWidth: 1.7,
|
||||
curveGlowWidth: 6,
|
||||
badgeGlowRadius: 14,
|
||||
},
|
||||
focus: {
|
||||
maxNeighbors: 16,
|
||||
ringCapacity: 6,
|
||||
ringGap: 250,
|
||||
primaryLabels: 6,
|
||||
},
|
||||
motion: {
|
||||
cameraMs: 380,
|
||||
},
|
||||
effects: {
|
||||
pathPulse: {
|
||||
minZoomTier: "structure",
|
||||
maxSegments: 18,
|
||||
speed: 0.22,
|
||||
radius: 11,
|
||||
glowAlpha: 0.92,
|
||||
},
|
||||
pathFlow: {
|
||||
minZoomTier: "structure",
|
||||
maxSegments: 14,
|
||||
speed: 0.36,
|
||||
spacing: 0.26,
|
||||
opacity: 0.92,
|
||||
radius: 3.8,
|
||||
},
|
||||
lens: {
|
||||
minZoomTier: "structure",
|
||||
radius: 136,
|
||||
feather: 78,
|
||||
glowAlpha: 0.18,
|
||||
edgeAlpha: 0.42,
|
||||
edgeLineWidth: 1.8,
|
||||
},
|
||||
temporalEmphasis: {
|
||||
minZoomTier: "structure",
|
||||
maxHighlights: 48,
|
||||
radiusMultiplier: 4.2,
|
||||
glowAlpha: 0.12,
|
||||
},
|
||||
semanticRegions: {
|
||||
minZoomTier: "overview",
|
||||
maxRegions: 3,
|
||||
minVisibleSamples: 14,
|
||||
fogResolutionScale: 0.22,
|
||||
splatRadius: 13,
|
||||
blurPasses: 2,
|
||||
densityThreshold: 0.16,
|
||||
contourThreshold: 0.28,
|
||||
minMaskPixels: 170,
|
||||
minOccupancyRatio: 0.0022,
|
||||
dominantMassRatio: 0.62,
|
||||
outerContourMinMaskPixels: 240,
|
||||
fogAlpha: 0.11,
|
||||
innerContourAlpha: 0.16,
|
||||
outerContourAlpha: 0.045,
|
||||
},
|
||||
contours: {
|
||||
minZoomTier: "overview",
|
||||
maxContours: 3,
|
||||
baseRadius: 88,
|
||||
glowAlpha: 0.055,
|
||||
},
|
||||
legend: {
|
||||
maxGroups: 8,
|
||||
},
|
||||
diagnostics: {
|
||||
enabledInDev: import.meta.env.DEV,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ZOOM_TIER_ORDER: GraphZoomTier[] = ["overview", "structure", "inspection"];
|
||||
|
||||
export function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function clamp(min: number, value: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function withAlpha(color: string | undefined, alpha: number): string {
|
||||
if (!color) {
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
|
||||
if (normalized.length === 6) {
|
||||
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (color.startsWith("rgba(")) {
|
||||
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
}
|
||||
|
||||
if (color.startsWith("rgb(")) {
|
||||
return color.replace("rgb(", "rgba(").replace(")", `, ${alpha})`);
|
||||
}
|
||||
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
|
||||
export function darkenHex(hexColor: string, amount: number): string {
|
||||
if (!hexColor.startsWith("#")) {
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
const hex = hexColor.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
|
||||
if (normalized.length !== 6) {
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
const clampChannel = (value: number) => clamp(0, value, 255);
|
||||
const red = clampChannel(Number.parseInt(normalized.slice(0, 2), 16) - amount);
|
||||
const green = clampChannel(Number.parseInt(normalized.slice(2, 4), 16) - amount);
|
||||
const blue = clampChannel(Number.parseInt(normalized.slice(4, 6), 16) - amount);
|
||||
|
||||
return `#${[red, green, blue].map((value) => value.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
export function blendHex(baseColor: string, tintColor: string, amount: number): string {
|
||||
if (!baseColor.startsWith("#") || !tintColor.startsWith("#")) {
|
||||
return tintColor || baseColor;
|
||||
}
|
||||
|
||||
const normalize = (value: string) => {
|
||||
const hex = value.slice(1);
|
||||
return hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
};
|
||||
|
||||
const base = normalize(baseColor);
|
||||
const tint = normalize(tintColor);
|
||||
if (base.length !== 6 || tint.length !== 6) {
|
||||
return tintColor || baseColor;
|
||||
}
|
||||
|
||||
const mix = clamp(0, amount, 1);
|
||||
const mixChannel = (left: number, right: number) => Math.round(left + (right - left) * mix);
|
||||
const channels = [0, 2, 4].map((offset) => {
|
||||
const left = Number.parseInt(base.slice(offset, offset + 2), 16);
|
||||
const right = Number.parseInt(tint.slice(offset, offset + 2), 16);
|
||||
return mixChannel(left, right).toString(16).padStart(2, "0");
|
||||
});
|
||||
|
||||
return `#${channels.join("")}`;
|
||||
}
|
||||
|
||||
export function getZoomTier(ratio: number): GraphZoomTier {
|
||||
if (ratio <= GRAPH_THEME.zoomTiers.inspection.maxRatio) {
|
||||
return "inspection";
|
||||
}
|
||||
if (ratio <= GRAPH_THEME.zoomTiers.structure.maxRatio) {
|
||||
return "structure";
|
||||
}
|
||||
return "overview";
|
||||
}
|
||||
|
||||
export function zoomTierAtLeast(current: GraphZoomTier, minimum: GraphZoomTier): boolean {
|
||||
return ZOOM_TIER_ORDER.indexOf(current) >= ZOOM_TIER_ORDER.indexOf(minimum);
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type {
|
||||
GraphEffectAvailability,
|
||||
GraphEffectToggle,
|
||||
} from "../types";
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const EFFECTS_PANEL_ID = "effects-panel";
|
||||
|
||||
type EffectRowConfig = {
|
||||
key: GraphEffectToggle;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const EFFECT_ROWS: EffectRowConfig[] = [
|
||||
{
|
||||
key: "pathPulseEnabled",
|
||||
label: "Path Pulse",
|
||||
description: "Animated pulse on the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "pathFlowEnabled",
|
||||
label: "Path Flow",
|
||||
description: "Directional flow accents along the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "lensEnabled",
|
||||
label: "Neighborhood Lens",
|
||||
description: "Local emphasis around the hovered or selected node.",
|
||||
},
|
||||
{
|
||||
key: "legendEnabled",
|
||||
label: "Semantic Legend",
|
||||
description: "Compact semantic group legend for graph orientation.",
|
||||
},
|
||||
];
|
||||
|
||||
function renderAvailabilityText(availability: GraphEffectAvailability) {
|
||||
if (availability.available) {
|
||||
if (typeof availability.visibleSegments === "number" && typeof availability.segmentCap === "number") {
|
||||
return `${availability.reason} · ${availability.visibleSegments}/${availability.segmentCap} segments`;
|
||||
}
|
||||
return availability.reason;
|
||||
}
|
||||
|
||||
return availability.detail ? `${availability.reason} · ${availability.detail}` : availability.reason;
|
||||
}
|
||||
|
||||
function collectLegendItems(context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0]) {
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, context.theme.effects.legend.maxGroups);
|
||||
}
|
||||
|
||||
function EffectToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
availability,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
availability: GraphEffectAvailability;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{label}</div>
|
||||
<div style={rowDescriptionStyle}>{description}</div>
|
||||
<div style={rowMetaStyle}>{renderAvailabilityText(availability)}</div>
|
||||
</div>
|
||||
<button type="button" onClick={onToggle} style={checked ? toggleButtonActiveStyle : toggleButtonStyle}>
|
||||
{checked ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const explorationEffectsPlugin: GraphPlugin = {
|
||||
id: "exploration-effects",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "effects-toggle",
|
||||
label: "Effects",
|
||||
title: "Open exploration effects controls",
|
||||
active: context.isPanelOpen(EFFECTS_PANEL_ID),
|
||||
order: 18,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: EFFECTS_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(EFFECTS_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectsState = context.getEffectsState();
|
||||
const diagnosticsSnapshot = context.getDiagnosticsSnapshot();
|
||||
const availability = diagnosticsSnapshot?.effectAvailability;
|
||||
const legendItems = effectsState.legendEnabled ? collectLegendItems(context) : [];
|
||||
|
||||
return {
|
||||
id: EFFECTS_PANEL_ID,
|
||||
title: "Effects",
|
||||
placement: "bottom",
|
||||
order: 8,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 420,
|
||||
preferredHeight: 320,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Exploration effects</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Path and focus</div>
|
||||
{EFFECT_ROWS.map((row) => (
|
||||
<EffectToggleRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
checked={effectsState[row.key]}
|
||||
availability={
|
||||
availability?.[
|
||||
row.key === "pathPulseEnabled"
|
||||
? "pathPulse"
|
||||
: row.key === "pathFlowEnabled"
|
||||
? "pathFlow"
|
||||
: row.key === "lensEnabled"
|
||||
? "lens"
|
||||
: "legend"
|
||||
] ?? {
|
||||
enabled: effectsState[row.key],
|
||||
available: false,
|
||||
reason: "Waiting for graph runtime",
|
||||
}
|
||||
}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{effectsState.legendEnabled ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Semantic legend</div>
|
||||
{legendItems.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{legendItems.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
background: item.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${item.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{item.group}</div>
|
||||
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Legend data will populate when graph metadata is available.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Diagnostics</div>
|
||||
<EffectToggleRow
|
||||
label="Dev Diagnostics"
|
||||
description="Inspect plugin, interaction, and effect gating state."
|
||||
checked={effectsState.diagnosticsEnabled}
|
||||
availability={
|
||||
availability?.diagnostics ?? {
|
||||
enabled: effectsState.diagnosticsEnabled,
|
||||
available: false,
|
||||
reason: "Waiting for graph runtime",
|
||||
}
|
||||
}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: "diagnosticsEnabled" })}
|
||||
/>
|
||||
{effectsState.diagnosticsEnabled && diagnosticsSnapshot ? (
|
||||
<details style={detailsStyle}>
|
||||
<summary style={summaryStyle}>Runtime snapshot</summary>
|
||||
<pre style={diagnosticsPreStyle}>
|
||||
{JSON.stringify(diagnosticsSnapshot, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#dce9f8",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const toggleRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "8px 0",
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const rowDescriptionStyle: CSSProperties = {
|
||||
color: "#a1b7cf",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#7fc6ff",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const toggleButtonStyle: CSSProperties = {
|
||||
minWidth: 52,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
color: "#cfe0f4",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const toggleButtonActiveStyle: CSSProperties = {
|
||||
...toggleButtonStyle,
|
||||
background: "rgba(31, 111, 235, 0.24)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.28)",
|
||||
color: "#eef6ff",
|
||||
};
|
||||
|
||||
const legendRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const legendSwatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const detailsStyle: CSSProperties = {
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
background: "rgba(0,0,0,0.14)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
padding: "10px 12px",
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const diagnosticsPreStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
padding: "0 12px 12px",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectAvailability,
|
||||
GraphEffectToggle,
|
||||
} from "../types";
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const EFFECTS_PANEL_ID = "effects-panel";
|
||||
|
||||
type EffectRowConfig = {
|
||||
key: GraphEffectToggle;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const SCENE_EFFECT_ROWS: EffectRowConfig[] = [
|
||||
{
|
||||
key: "pathPulseEnabled",
|
||||
label: "Path Pulse",
|
||||
description: "Animated pulse on the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "pathFlowEnabled",
|
||||
label: "Path Flow",
|
||||
description: "Directional flow accents along the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "lensEnabled",
|
||||
label: "Neighborhood Lens",
|
||||
description: "Local emphasis around the hovered or selected node.",
|
||||
},
|
||||
{
|
||||
key: "temporalEmphasisEnabled",
|
||||
label: "Temporal Emphasis",
|
||||
description: "Subtle glow around temporally relevant nodes in the active time window.",
|
||||
},
|
||||
{
|
||||
key: "semanticRegionsEnabled",
|
||||
label: "Semantic Regions",
|
||||
description: "Quiet semantic hulls around the strongest visible topic clusters.",
|
||||
},
|
||||
{
|
||||
key: "contoursEnabled",
|
||||
label: "Contours",
|
||||
description: "Low-contrast density halos around the strongest visible anchors.",
|
||||
},
|
||||
{
|
||||
key: "legendEnabled",
|
||||
label: "Regions Summary",
|
||||
description: "Keep the regions and signals summary visible in the Effects panel.",
|
||||
},
|
||||
];
|
||||
|
||||
const INTELLIGENCE_EFFECT_ROWS: EffectRowConfig[] = [
|
||||
{
|
||||
key: "pathfindingEnabled",
|
||||
label: "Directed Pathfinding",
|
||||
description: "Compare the traced path against a strict local directed shortest path.",
|
||||
},
|
||||
{
|
||||
key: "communitiesEnabled",
|
||||
label: "Community Regions",
|
||||
description: "Detect stable Louvain communities for orientation and scene grouping.",
|
||||
},
|
||||
{
|
||||
key: "centralityEnabled",
|
||||
label: "Centrality Ranking",
|
||||
description: "Rank the strongest graph anchors for labels, regions, and navigation.",
|
||||
},
|
||||
];
|
||||
|
||||
const AVAILABILITY_KEYS: Record<GraphEffectToggle, keyof GraphDiagnosticsSnapshot["effectAvailability"]> = {
|
||||
pathPulseEnabled: "pathPulse",
|
||||
pathFlowEnabled: "pathFlow",
|
||||
lensEnabled: "lens",
|
||||
temporalEmphasisEnabled: "temporalEmphasis",
|
||||
semanticRegionsEnabled: "semanticRegions",
|
||||
contoursEnabled: "contours",
|
||||
pathfindingEnabled: "pathfinding",
|
||||
communitiesEnabled: "communities",
|
||||
centralityEnabled: "centrality",
|
||||
legendEnabled: "legend",
|
||||
diagnosticsEnabled: "diagnostics",
|
||||
};
|
||||
|
||||
function renderAvailabilityText(availability: GraphEffectAvailability) {
|
||||
if (availability.available) {
|
||||
if (typeof availability.visibleSegments === "number" && typeof availability.segmentCap === "number") {
|
||||
return `${availability.reason} - ${availability.visibleSegments}/${availability.segmentCap} segments`;
|
||||
}
|
||||
return availability.reason;
|
||||
}
|
||||
|
||||
return availability.detail ? `${availability.reason} - ${availability.detail}` : availability.reason;
|
||||
}
|
||||
|
||||
function collectFallbackLegendItems(context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0]) {
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, context.theme.effects.legend.maxGroups);
|
||||
}
|
||||
|
||||
function resolveAvailability(
|
||||
availabilityMap: GraphDiagnosticsSnapshot["effectAvailability"] | undefined,
|
||||
key: GraphEffectToggle,
|
||||
enabled: boolean,
|
||||
): GraphEffectAvailability {
|
||||
return availabilityMap?.[AVAILABILITY_KEYS[key]] ?? {
|
||||
enabled,
|
||||
available: false,
|
||||
reason: "Waiting for graph runtime",
|
||||
};
|
||||
}
|
||||
|
||||
function EffectToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
availability,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
availability: GraphEffectAvailability;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{label}</div>
|
||||
<div style={rowDescriptionStyle}>{description}</div>
|
||||
<div style={rowMetaStyle}>{renderAvailabilityText(availability)}</div>
|
||||
</div>
|
||||
<button type="button" onClick={onToggle} style={checked ? toggleButtonActiveStyle : toggleButtonStyle}>
|
||||
{checked ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRegionsAndSignals(
|
||||
context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0],
|
||||
analytics: GraphAnalyticsSnapshot | null,
|
||||
) {
|
||||
const fallbackLegendItems = collectFallbackLegendItems(context);
|
||||
const semanticRegions = analytics?.semanticRegions.summaries ?? [];
|
||||
const communities = analytics?.communities.summaries ?? [];
|
||||
const centrality = analytics?.centrality.topNodes ?? [];
|
||||
const directedPath = analytics?.directedPath ?? null;
|
||||
|
||||
if (!semanticRegions.length && !communities.length && !centrality.length && !fallbackLegendItems.length && !directedPath) {
|
||||
return <div style={emptyTextStyle}>Regions and intelligence summaries will populate when graph analytics are ready.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{semanticRegions.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Semantic regions</div>
|
||||
{semanticRegions.map((region) => (
|
||||
<div key={region.semanticGroup} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
background: region.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${region.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{region.semanticGroup}</div>
|
||||
<div style={rowMetaStyle}>
|
||||
{region.visibleNodeCount.toLocaleString()} visible / {region.nodeCount.toLocaleString()} total
|
||||
</div>
|
||||
</div>
|
||||
<div style={signalBadgeStyle}>{region.anchorLabel}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{communities.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Community anchors</div>
|
||||
{communities.slice(0, 3).map((community) => (
|
||||
<div key={community.communityId} style={signalRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{community.anchorLabel}</div>
|
||||
<div style={rowMetaStyle}>
|
||||
Community {community.communityId} - {community.visibleNodeCount} visible / {community.nodeCount} total
|
||||
</div>
|
||||
</div>
|
||||
<div style={signalBadgeStyle}>{community.dominantSemanticGroup}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{centrality.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Centrality leaders</div>
|
||||
{centrality.slice(0, 3).map((node) => (
|
||||
<div key={node.id} style={signalRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{node.label}</div>
|
||||
<div style={rowMetaStyle}>
|
||||
{node.semanticGroup} - score {node.score.toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={signalBadgeStyle}>deg {node.degree.toFixed(3)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{directedPath ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Directed pathfinding</div>
|
||||
<div style={signalRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{directedPath.ready ? "Local directed path ready" : "Waiting for path context"}</div>
|
||||
<div style={rowMetaStyle}>{directedPath.reason}</div>
|
||||
</div>
|
||||
{directedPath.ready ? (
|
||||
<div style={signalBadgeStyle}>
|
||||
{directedPath.length} hops{directedPath.verifiedAgainstActivePath ? " - match" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!semanticRegions.length && !communities.length && !centrality.length && fallbackLegendItems.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Fallback semantic legend</div>
|
||||
{fallbackLegendItems.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
background: item.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${item.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{item.group}</div>
|
||||
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const explorationEffectsPluginPhaseC: GraphPlugin = {
|
||||
id: "exploration-effects",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "effects-toggle",
|
||||
label: "Effects",
|
||||
title: "Open exploration effects controls",
|
||||
active: context.isPanelOpen(EFFECTS_PANEL_ID),
|
||||
order: 18,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: EFFECTS_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(EFFECTS_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectsState = context.getEffectsState();
|
||||
const diagnosticsSnapshot = context.getDiagnosticsSnapshot();
|
||||
const analyticsSnapshot = context.getAnalyticsSnapshot();
|
||||
const availability = diagnosticsSnapshot?.effectAvailability;
|
||||
const showSignalsSection =
|
||||
effectsState.legendEnabled
|
||||
|| effectsState.semanticRegionsEnabled
|
||||
|| effectsState.communitiesEnabled
|
||||
|| effectsState.centralityEnabled
|
||||
|| effectsState.pathfindingEnabled;
|
||||
|
||||
return {
|
||||
id: EFFECTS_PANEL_ID,
|
||||
title: "Effects",
|
||||
placement: "bottom",
|
||||
order: 8,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 460,
|
||||
preferredHeight: 360,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Exploration effects</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Scene effects</div>
|
||||
{SCENE_EFFECT_ROWS.map((row) => (
|
||||
<EffectToggleRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
checked={effectsState[row.key]}
|
||||
availability={resolveAvailability(availability, row.key, effectsState[row.key])}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Graph intelligence</div>
|
||||
{INTELLIGENCE_EFFECT_ROWS.map((row) => (
|
||||
<EffectToggleRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
checked={effectsState[row.key]}
|
||||
availability={resolveAvailability(availability, row.key, effectsState[row.key])}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showSignalsSection ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Regions and signals</div>
|
||||
{renderRegionsAndSignals(context, analyticsSnapshot)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Diagnostics</div>
|
||||
<EffectToggleRow
|
||||
label="Dev Diagnostics"
|
||||
description="Inspect plugin, interaction, and effect gating state."
|
||||
checked={effectsState.diagnosticsEnabled}
|
||||
availability={resolveAvailability(availability, "diagnosticsEnabled", effectsState.diagnosticsEnabled)}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: "diagnosticsEnabled" })}
|
||||
/>
|
||||
{effectsState.diagnosticsEnabled && diagnosticsSnapshot ? (
|
||||
<details style={detailsStyle}>
|
||||
<summary style={summaryStyle}>Runtime snapshot</summary>
|
||||
<pre style={diagnosticsPreStyle}>
|
||||
{JSON.stringify({ diagnostics: diagnosticsSnapshot, analytics: analyticsSnapshot }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#dce9f8",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const subsectionTitleStyle: CSSProperties = {
|
||||
color: "#9cc4ec",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const toggleRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "8px 0",
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const rowDescriptionStyle: CSSProperties = {
|
||||
color: "#a1b7cf",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#7fc6ff",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const toggleButtonStyle: CSSProperties = {
|
||||
minWidth: 52,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
color: "#cfe0f4",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const toggleButtonActiveStyle: CSSProperties = {
|
||||
...toggleButtonStyle,
|
||||
background: "rgba(31, 111, 235, 0.24)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.28)",
|
||||
color: "#eef6ff",
|
||||
};
|
||||
|
||||
const legendRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const legendSwatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const signalRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
};
|
||||
|
||||
const signalBadgeStyle: CSSProperties = {
|
||||
padding: "5px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(31, 111, 235, 0.16)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.16)",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const detailsStyle: CSSProperties = {
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
background: "rgba(0,0,0,0.14)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
padding: "10px 12px",
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const diagnosticsPreStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
padding: "0 12px 12px",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export { explorationEffectsPluginPhaseC as explorationEffectsPlugin } from "./explorationEffectsPluginPhaseC";
|
||||
export { legendPlugin } from "./legendPlugin";
|
||||
export { neighborhoodPanelPlugin } from "./neighborhoodPanelPlugin";
|
||||
export { temporalOverlayPlugin } from "./temporalOverlayPlugin";
|
||||
export type {
|
||||
GraphPlugin,
|
||||
GraphPluginActionRequest,
|
||||
GraphPluginContext,
|
||||
GraphInspectorState,
|
||||
GraphPluginId,
|
||||
GraphPluginOverlayDescriptor,
|
||||
GraphPluginPanelDescriptor,
|
||||
GraphPluginRegistryEntry,
|
||||
GraphPluginToolbarItem,
|
||||
GraphTemporalState,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const LEGEND_PANEL_ID = "legend-panel";
|
||||
const MAX_GROUPS = 8;
|
||||
|
||||
export const legendPlugin: GraphPlugin = {
|
||||
id: "legend",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "legend-toggle",
|
||||
label: "Legend",
|
||||
title: "Toggle semantic legend",
|
||||
active: context.isPanelOpen(LEGEND_PANEL_ID),
|
||||
order: 20,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: LEGEND_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(LEGEND_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
const items = [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, MAX_GROUPS);
|
||||
|
||||
return {
|
||||
id: LEGEND_PANEL_ID,
|
||||
title: "Legend",
|
||||
placement: "bottom",
|
||||
order: 10,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 320,
|
||||
preferredHeight: 220,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Semantic groups</div>
|
||||
{items.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...swatchStyle,
|
||||
background: item.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 18px ${item.color}44`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{item.group}</div>
|
||||
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Legend will populate when the graph metadata is available.</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const legendRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const swatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const NEIGHBORHOOD_PANEL_ID = "neighborhood-panel";
|
||||
const MAX_NEIGHBORS = 10;
|
||||
|
||||
function maxWeightBetween(graphRef: any, sourceId: string, targetId: string): number {
|
||||
let weight = 0;
|
||||
graphRef.forEachDirectedEdge(sourceId, targetId, (_edgeId: string, attrs: { weight?: number }) => {
|
||||
weight = Math.max(weight, Number(attrs.weight ?? 0));
|
||||
});
|
||||
return weight;
|
||||
}
|
||||
|
||||
function formatNeighborMeta(neighbor: { nodeType: string; degree: number; weight: number }) {
|
||||
const parts = [neighbor.nodeType, `degree ${neighbor.degree}`];
|
||||
if (neighbor.weight > 0) {
|
||||
parts.push(`weight ${neighbor.weight.toFixed(2)}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
id: "neighborhood-panel",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "neighborhood-toggle",
|
||||
label: "Neighbors",
|
||||
title: "Toggle neighborhood panel",
|
||||
active: context.isPanelOpen(NEIGHBORHOOD_PANEL_ID),
|
||||
order: 30,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: NEIGHBORHOOD_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(NEIGHBORHOOD_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected = context.getSelectedNodeState();
|
||||
if (!selected) {
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
title: "Neighborhood",
|
||||
placement: "bottom",
|
||||
order: 20,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 360,
|
||||
preferredHeight: 260,
|
||||
content: <div style={emptyTextStyle}>Select a node to inspect its local neighborhood.</div>,
|
||||
};
|
||||
}
|
||||
|
||||
const neighbors = context.graph
|
||||
.neighbors(selected.id)
|
||||
.map((neighborId) => {
|
||||
const attrs = context.graph.getNodeAttributes(neighborId);
|
||||
const weight = Math.max(
|
||||
maxWeightBetween(context.graph, selected.id, neighborId),
|
||||
maxWeightBetween(context.graph, neighborId, selected.id),
|
||||
);
|
||||
return {
|
||||
id: neighborId,
|
||||
label: String(attrs.label || neighborId),
|
||||
nodeType: String(attrs.nodeType || "Entity"),
|
||||
color: String(attrs.baseColor || attrs.color || context.theme.palette.semantic[0]),
|
||||
weight,
|
||||
degree: context.graph.degree(neighborId),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (right.weight !== left.weight) {
|
||||
return right.weight - left.weight;
|
||||
}
|
||||
if (right.degree !== left.degree) {
|
||||
return right.degree - left.degree;
|
||||
}
|
||||
return left.label.localeCompare(right.label);
|
||||
})
|
||||
.slice(0, MAX_NEIGHBORS);
|
||||
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
title: "Neighborhood",
|
||||
placement: "bottom",
|
||||
order: 20,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 360,
|
||||
preferredHeight: 260,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>{selected.label}</div>
|
||||
<div style={summaryStyle}>
|
||||
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
|
||||
</div>
|
||||
{neighbors.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{neighbors.map((neighbor) => (
|
||||
<button
|
||||
key={neighbor.id}
|
||||
type="button"
|
||||
onClick={() => context.dispatchAction({ type: "selectNode", nodeId: neighbor.id })}
|
||||
style={neighborButtonStyle}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...swatchStyle,
|
||||
background: neighbor.color,
|
||||
boxShadow: `0 0 16px ${neighbor.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1, textAlign: "left" }}>
|
||||
<div style={rowTitleStyle}>{neighbor.label}</div>
|
||||
<div style={rowMetaStyle}>{formatNeighborMeta(neighbor)}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No direct neighbors are available for this node.</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const neighborButtonStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
borderRadius: 12,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const swatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const TEMPORAL_PANEL_ID = "temporal-panel";
|
||||
|
||||
function formatTemporalLabel(value: Date | null) {
|
||||
if (!value) {
|
||||
return "No time selected";
|
||||
}
|
||||
return `${value.getFullYear()}/${String(value.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export const temporalOverlayPlugin: GraphPlugin = {
|
||||
id: "temporal-overlay",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "temporal-toggle",
|
||||
label: "Temporal",
|
||||
title: "Toggle temporal context panel",
|
||||
active: context.isPanelOpen(TEMPORAL_PANEL_ID),
|
||||
order: 40,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: TEMPORAL_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderOverlay: (context) => {
|
||||
const temporal = context.getTemporalState();
|
||||
if (!temporal?.currentTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = formatTemporalLabel(temporal.currentTime);
|
||||
return {
|
||||
id: "temporal-overlay-chip",
|
||||
layer: 1,
|
||||
order: 10,
|
||||
element: (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 140,
|
||||
bottom: 26,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(127, 208, 255, 0.18)",
|
||||
background: "linear-gradient(135deg, rgba(6, 15, 27, 0.88), rgba(11, 22, 39, 0.76))",
|
||||
boxShadow: "0 12px 30px rgba(0, 0, 0, 0.28)",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#7fc6ff", fontWeight: 700 }}>Temporal</span>
|
||||
<span>{label}</span>
|
||||
{typeof temporal.activeNodeCount === "number" ? (
|
||||
<span style={{ color: "#8ea4be" }}>{temporal.activeNodeCount.toLocaleString()} active</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(TEMPORAL_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const temporal = context.getTemporalState();
|
||||
return {
|
||||
id: TEMPORAL_PANEL_ID,
|
||||
title: "Temporal Context",
|
||||
placement: "bottom",
|
||||
order: 30,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 320,
|
||||
preferredHeight: 220,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Current scrubber state</div>
|
||||
<div style={detailRowStyle}>
|
||||
<span style={detailLabelStyle}>Current</span>
|
||||
<span style={detailValueStyle}>{formatTemporalLabel(temporal?.currentTime ?? null)}</span>
|
||||
</div>
|
||||
<div style={detailRowStyle}>
|
||||
<span style={detailLabelStyle}>Bounds</span>
|
||||
<span style={detailValueStyle}>
|
||||
{(temporal?.minDate ?? "1970")} → {(temporal?.maxDate ?? "2030")}
|
||||
</span>
|
||||
</div>
|
||||
<div style={detailRowStyle}>
|
||||
<span style={detailLabelStyle}>Active nodes</span>
|
||||
<span style={detailValueStyle}>
|
||||
{typeof temporal?.activeNodeCount === "number" ? temporal.activeNodeCount.toLocaleString() : "All"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const detailRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const detailLabelStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const detailValueStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type Graph from "graphology";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../../store/graphStore";
|
||||
import type { GraphTheme } from "../graphTheme";
|
||||
import type { GraphSceneRuntime } from "../scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphEffectToggle,
|
||||
GraphInteractionState,
|
||||
GraphLoadSummary,
|
||||
GraphSelectedNodeState,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "../types";
|
||||
|
||||
export type { GraphTemporalState } from "../types";
|
||||
|
||||
export type GraphPluginId = string;
|
||||
export type GraphPluginPanelPlacement = "side" | "bottom";
|
||||
|
||||
export interface GraphInspectorState {
|
||||
selectedNodeId: string | null;
|
||||
ownsSelectionDetails: boolean;
|
||||
}
|
||||
|
||||
export type GraphPluginActionRequest =
|
||||
| { type: "fitView" }
|
||||
| { type: "focusNode"; nodeId: string }
|
||||
| { type: "selectNode"; nodeId: string }
|
||||
| { type: "setViewMode"; viewMode: GraphViewMode }
|
||||
| { type: "toggleEffect"; effect: GraphEffectToggle }
|
||||
| { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean }
|
||||
| { type: "togglePanel"; panelId: string }
|
||||
| { type: "openPanel"; panelId: string }
|
||||
| { type: "closePanel"; panelId: string };
|
||||
|
||||
export interface GraphPluginToolbarItem {
|
||||
id: string;
|
||||
label: string;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
order?: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface GraphPluginPanelDescriptor {
|
||||
id: string;
|
||||
title: string;
|
||||
placement: GraphPluginPanelPlacement;
|
||||
order?: number;
|
||||
defaultOpen?: boolean;
|
||||
preferredHeight?: number;
|
||||
preferredWidth?: number;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export interface GraphPluginOverlayDescriptor {
|
||||
id: string;
|
||||
layer?: number;
|
||||
order?: number;
|
||||
element: ReactNode;
|
||||
}
|
||||
|
||||
export interface GraphPluginContext {
|
||||
readonly scene: GraphSceneRuntime | null;
|
||||
readonly graph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
readonly displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
readonly theme: GraphTheme;
|
||||
getInteractionState: () => GraphInteractionState;
|
||||
getSelectedNodeState: () => GraphSelectedNodeState | null;
|
||||
getInspectorState: () => GraphInspectorState;
|
||||
getGraphSummary: () => GraphLoadSummary | null;
|
||||
getTemporalState: () => GraphTemporalState | null;
|
||||
getEffectsState: () => GraphEffectsState;
|
||||
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
|
||||
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
|
||||
isPanelOpen: (panelId: string) => boolean;
|
||||
dispatchAction: (action: GraphPluginActionRequest) => void;
|
||||
}
|
||||
|
||||
export interface GraphPlugin {
|
||||
id: GraphPluginId;
|
||||
mount: (context: GraphPluginContext) => void;
|
||||
unmount: (context: GraphPluginContext) => void;
|
||||
onStateChange: (context: GraphPluginContext, interactionState: GraphInteractionState) => void;
|
||||
renderOverlay?: (
|
||||
context: GraphPluginContext,
|
||||
) => GraphPluginOverlayDescriptor | GraphPluginOverlayDescriptor[] | null;
|
||||
renderPanel?: (
|
||||
context: GraphPluginContext,
|
||||
) => GraphPluginPanelDescriptor | GraphPluginPanelDescriptor[] | null;
|
||||
toolbarItems?: (context: GraphPluginContext) => GraphPluginToolbarItem[];
|
||||
}
|
||||
|
||||
export interface GraphPluginRegistryEntry {
|
||||
plugin: GraphPlugin;
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from "react";
|
||||
import type Graph from "graphology";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
GraphLayoutSource,
|
||||
GraphLayoutStatus,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
export type GraphSceneGraph = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
export type GraphSceneRenderer = "sigma";
|
||||
|
||||
export interface GraphSceneRuntime {
|
||||
renderer: GraphSceneRenderer;
|
||||
scene: unknown;
|
||||
graph: GraphSceneGraph;
|
||||
displayGraph: GraphSceneGraph;
|
||||
requestRender: () => void;
|
||||
getCameraState: () => GraphCameraState | null;
|
||||
}
|
||||
|
||||
export interface GraphSceneEventMap {
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
onEdgeSelect?: (edgeId: string) => void;
|
||||
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
|
||||
onCameraStateChange?: (cameraState: GraphCameraState) => void;
|
||||
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
|
||||
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
|
||||
onRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
|
||||
}
|
||||
|
||||
export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
selectedNodeId: string;
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
effectsState: GraphEffectsState;
|
||||
temporalState?: GraphTemporalState | null;
|
||||
isLayoutRunning: boolean;
|
||||
onLayoutRunningChange?: (running: boolean) => void;
|
||||
layoutSource?: GraphLayoutSource;
|
||||
onLayoutStatusChange?: (status: GraphLayoutStatus) => void;
|
||||
viewMode: GraphViewMode;
|
||||
className?: string;
|
||||
showFitViewButton?: boolean;
|
||||
pluginOverlays?: ReactNode[];
|
||||
}
|
||||
|
||||
export interface GraphSceneHandle {
|
||||
fitView: () => void;
|
||||
focusNode: (nodeId: string) => void;
|
||||
getRuntime: () => GraphSceneRuntime | null;
|
||||
setLayoutRunning?: (running: boolean) => void;
|
||||
}
|
||||
|
||||
export type GraphSceneAdapter = ForwardRefExoticComponent<
|
||||
GraphSceneProps & RefAttributes<GraphSceneHandle>
|
||||
>;
|
||||
@@ -0,0 +1,336 @@
|
||||
import EdgeCurveProgram, { EdgeCurvedArrowProgram } from "@sigma/edge-curve";
|
||||
import { NodeProgram, type ProgramInfo } from "sigma/rendering";
|
||||
import { DEFAULT_EDGE_PROGRAM_CLASSES, DEFAULT_NODE_PROGRAM_CLASSES } from "sigma/settings";
|
||||
import type { NodeDisplayData, RenderParams } from "sigma/types";
|
||||
import { floatColor } from "sigma/utils";
|
||||
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
||||
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
|
||||
type SemanticaNodeDrawData = {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
label: string;
|
||||
color: string;
|
||||
shellColor?: string;
|
||||
coreScale?: number;
|
||||
borderColor?: string;
|
||||
ringColor?: string;
|
||||
ringSize?: number;
|
||||
nodeType?: string;
|
||||
};
|
||||
|
||||
const MINERAL_DISC_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
|
||||
const MINERAL_DISC_FRAGMENT_SHADER = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
float discMetric(vec2 point) {
|
||||
return length(point);
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 unit = v_diffVector / max(v_radius, 0.0001);
|
||||
float metric = discMetric(unit);
|
||||
float aa = (2.4 * u_correctionRatio) / max(v_radius, 1.0);
|
||||
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, metric);
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
if (alpha <= 0.0) {
|
||||
gl_FragColor = transparent;
|
||||
} else {
|
||||
gl_FragColor = v_color;
|
||||
gl_FragColor.a *= bias;
|
||||
}
|
||||
#else
|
||||
if (alpha <= 0.0) {
|
||||
gl_FragColor = transparent;
|
||||
return;
|
||||
}
|
||||
|
||||
float ringNorm = clamp(v_ringSize / max(v_radius, 1.0), 0.0, 0.45);
|
||||
float ringStart = max(0.0, 1.0 - ringNorm);
|
||||
float coreEdge = clamp(v_coreScale, 0.06, 0.78);
|
||||
float coreBlend = 1.0 - smoothstep(max(coreEdge - 0.14, 0.0), coreEdge, metric);
|
||||
float bodyLight = 1.0 - smoothstep(0.0, 0.82, metric);
|
||||
vec4 color = mix(v_shellColor, v_coreColor, coreBlend);
|
||||
color.rgb += vec3(0.022) * pow(bodyLight, 1.45);
|
||||
|
||||
if (ringNorm > 0.0 && metric >= ringStart) {
|
||||
color = v_ringColor;
|
||||
}
|
||||
color.a *= alpha;
|
||||
gl_FragColor = color;
|
||||
#endif
|
||||
}
|
||||
`;
|
||||
|
||||
const MINERAL_DISC_VERTEX_SHADER = /* glsl */ `
|
||||
attribute vec4 a_id;
|
||||
attribute vec2 a_position;
|
||||
attribute float a_size;
|
||||
attribute float a_angle;
|
||||
attribute vec4 a_coreColor;
|
||||
attribute vec4 a_shellColor;
|
||||
attribute vec4 a_ringColor;
|
||||
attribute float a_ringSize;
|
||||
attribute float a_coreScale;
|
||||
|
||||
uniform mat3 u_matrix;
|
||||
uniform float u_sizeRatio;
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
|
||||
void main() {
|
||||
float size = a_size * u_correctionRatio / u_sizeRatio * 4.0;
|
||||
vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle));
|
||||
vec2 position = a_position + diffVector;
|
||||
|
||||
gl_Position = vec4(
|
||||
(u_matrix * vec3(position, 1)).xy,
|
||||
0,
|
||||
1
|
||||
);
|
||||
|
||||
v_diffVector = diffVector;
|
||||
v_radius = size / 2.0;
|
||||
v_ringSize = a_ringSize;
|
||||
v_coreScale = a_coreScale;
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
v_color = a_id;
|
||||
#else
|
||||
v_coreColor = a_coreColor;
|
||||
v_shellColor = a_shellColor;
|
||||
v_ringColor = a_ringColor;
|
||||
#endif
|
||||
|
||||
v_color.a *= bias;
|
||||
}
|
||||
`;
|
||||
|
||||
class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[number]> {
|
||||
static readonly ANGLE_1 = 0;
|
||||
static readonly ANGLE_2 = (2 * Math.PI) / 3;
|
||||
static readonly ANGLE_3 = (4 * Math.PI) / 3;
|
||||
|
||||
drawLabel = drawSemanticaNodeLabel;
|
||||
|
||||
drawHover = drawSemanticaNodeHover;
|
||||
|
||||
getDefinition() {
|
||||
return {
|
||||
VERTICES: 3,
|
||||
VERTEX_SHADER_SOURCE: MINERAL_DISC_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: MINERAL_DISC_FRAGMENT_SHADER,
|
||||
METHOD: WebGLRenderingContext.TRIANGLES,
|
||||
UNIFORMS: MINERAL_DISC_UNIFORMS,
|
||||
ATTRIBUTES: [
|
||||
{ name: "a_position", size: 2, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_size", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_coreColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_shellColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringSize", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_coreScale", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_id", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
],
|
||||
CONSTANT_ATTRIBUTES: [
|
||||
{ name: "a_angle", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
],
|
||||
CONSTANT_DATA: [
|
||||
[MineralDiscNodeProgram.ANGLE_1],
|
||||
[MineralDiscNodeProgram.ANGLE_2],
|
||||
[MineralDiscNodeProgram.ANGLE_3],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
processVisibleItem(nodeIndex: number, startIndex: number, data: NodeDisplayData & SemanticaNodeDrawData): void {
|
||||
const array = this.array;
|
||||
const ringColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
|
||||
|
||||
array[startIndex++] = data.x;
|
||||
array[startIndex++] = data.y;
|
||||
array[startIndex++] = data.size;
|
||||
array[startIndex++] = floatColor(data.color || GRAPH_THEME.palette.overview.nodeCore);
|
||||
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, GRAPH_THEME.palette.overview.nodeShellAlpha));
|
||||
array[startIndex++] = floatColor(ringColor);
|
||||
array[startIndex++] = data.ringSize || 0;
|
||||
array[startIndex++] = data.coreScale ?? 0.22;
|
||||
array[startIndex++] = nodeIndex;
|
||||
}
|
||||
|
||||
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof MINERAL_DISC_UNIFORMS)[number]>): void {
|
||||
gl.uniform1f(uniformLocations.u_correctionRatio, params.correctionRatio);
|
||||
gl.uniform1f(uniformLocations.u_sizeRatio, params.sizeRatio);
|
||||
gl.uniformMatrix3fv(uniformLocations.u_matrix, false, params.matrix);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAccentBorderColor(
|
||||
ringSize: number | undefined,
|
||||
ringColor: string | undefined,
|
||||
borderColor: string | undefined,
|
||||
fallbackColor: string,
|
||||
) {
|
||||
if (typeof ringSize === "number" && ringSize > 0 && ringColor) {
|
||||
return ringColor;
|
||||
}
|
||||
|
||||
return borderColor || fallbackColor;
|
||||
}
|
||||
|
||||
function drawRoundedRect(
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
) {
|
||||
const clampedRadius = Math.max(0, Math.min(radius, Math.min(width, height) / 2));
|
||||
context.beginPath();
|
||||
context.moveTo(x + clampedRadius, y);
|
||||
context.lineTo(x + width - clampedRadius, y);
|
||||
context.quadraticCurveTo(x + width, y, x + width, y + clampedRadius);
|
||||
context.lineTo(x + width, y + height - clampedRadius);
|
||||
context.quadraticCurveTo(x + width, y + height, x + width - clampedRadius, y + height);
|
||||
context.lineTo(x + clampedRadius, y + height);
|
||||
context.quadraticCurveTo(x, y + height, x, y + height - clampedRadius);
|
||||
context.lineTo(x, y + clampedRadius);
|
||||
context.quadraticCurveTo(x, y, x + clampedRadius, y);
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
export const drawSemanticaNodeLabel: NodeLabelDrawingFunction = (context, rawData) => {
|
||||
const data = rawData as typeof rawData & SemanticaNodeDrawData;
|
||||
if (!data.label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chipTheme = GRAPH_THEME.labels.chip;
|
||||
const fontSize = Math.max(
|
||||
chipTheme.fontSize,
|
||||
Math.min(chipTheme.maxFontSize, data.size * chipTheme.sizeScale),
|
||||
);
|
||||
const font = `${chipTheme.fontWeight} ${fontSize}px ${chipTheme.fontFamily}`;
|
||||
const paddingX = chipTheme.paddingX;
|
||||
const paddingY = chipTheme.paddingY;
|
||||
const borderColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, chipTheme.borderColor);
|
||||
|
||||
context.save();
|
||||
context.font = font;
|
||||
context.textBaseline = "middle";
|
||||
const metrics = context.measureText(data.label);
|
||||
const width = metrics.width + paddingX * 2;
|
||||
const height = fontSize + paddingY * 2;
|
||||
const x = data.x + Math.max(data.size * 0.7, chipTheme.offsetX);
|
||||
const y = data.y - Math.max(data.size * 0.9, chipTheme.offsetY) - height;
|
||||
|
||||
context.shadowColor = withAlpha(chipTheme.shadowColor, chipTheme.shadowAlpha);
|
||||
context.shadowBlur = chipTheme.shadowBlur;
|
||||
context.fillStyle = chipTheme.background;
|
||||
drawRoundedRect(context, x, y, width, height, chipTheme.radius);
|
||||
context.fill();
|
||||
|
||||
context.shadowBlur = 0;
|
||||
context.strokeStyle = withAlpha(borderColor, chipTheme.borderAlpha);
|
||||
context.lineWidth = 1;
|
||||
drawRoundedRect(context, x, y, width, height, chipTheme.radius);
|
||||
context.stroke();
|
||||
|
||||
context.fillStyle = chipTheme.textColor;
|
||||
context.fillText(data.label, x + paddingX, y + height / 2);
|
||||
context.restore();
|
||||
};
|
||||
|
||||
export const drawSemanticaNodeHover: NodeHoverDrawingFunction = (context, rawData) => {
|
||||
const data = rawData as typeof rawData & SemanticaNodeDrawData;
|
||||
if (!data.label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoverTheme = GRAPH_THEME.labels.hoverCard;
|
||||
const borderColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, hoverTheme.borderColor);
|
||||
const metaLabel = (typeof data.nodeType === "string" && data.nodeType.trim().length > 0)
|
||||
? data.nodeType.replaceAll("_", " ").toUpperCase()
|
||||
: "NODE";
|
||||
|
||||
context.save();
|
||||
context.textBaseline = "top";
|
||||
|
||||
const titleFont = `${hoverTheme.titleWeight} ${hoverTheme.titleSize}px ${hoverTheme.fontFamily}`;
|
||||
const metaFont = `${hoverTheme.metaWeight} ${hoverTheme.metaSize}px ${hoverTheme.fontFamily}`;
|
||||
context.font = titleFont;
|
||||
const titleWidth = context.measureText(data.label).width;
|
||||
context.font = metaFont;
|
||||
const metaWidth = context.measureText(metaLabel).width;
|
||||
|
||||
const width = Math.max(titleWidth, metaWidth) + hoverTheme.paddingX * 2;
|
||||
const height = hoverTheme.paddingY * 2 + hoverTheme.titleSize + hoverTheme.metaGap + hoverTheme.metaSize;
|
||||
const x = data.x + Math.max(data.size * 0.9, hoverTheme.offsetX);
|
||||
const y = data.y - Math.max(data.size * 1.1, hoverTheme.offsetY) - height;
|
||||
|
||||
context.shadowColor = withAlpha(hoverTheme.shadowColor, hoverTheme.shadowAlpha);
|
||||
context.shadowBlur = hoverTheme.shadowBlur;
|
||||
context.fillStyle = hoverTheme.background;
|
||||
drawRoundedRect(context, x, y, width, height, hoverTheme.radius);
|
||||
context.fill();
|
||||
|
||||
context.shadowBlur = 0;
|
||||
context.strokeStyle = withAlpha(borderColor, hoverTheme.borderAlpha);
|
||||
context.lineWidth = 1.2;
|
||||
drawRoundedRect(context, x, y, width, height, hoverTheme.radius);
|
||||
context.stroke();
|
||||
|
||||
context.fillStyle = hoverTheme.textColor;
|
||||
context.font = titleFont;
|
||||
context.fillText(data.label, x + hoverTheme.paddingX, y + hoverTheme.paddingY);
|
||||
|
||||
context.fillStyle = hoverTheme.metaColor;
|
||||
context.font = metaFont;
|
||||
context.fillText(
|
||||
metaLabel,
|
||||
x + hoverTheme.paddingX,
|
||||
y + hoverTheme.paddingY + hoverTheme.titleSize + hoverTheme.metaGap,
|
||||
);
|
||||
|
||||
context.restore();
|
||||
};
|
||||
|
||||
export const SEMANTICA_NODE_PROGRAM_CLASSES = {
|
||||
...DEFAULT_NODE_PROGRAM_CLASSES,
|
||||
circle: MineralDiscNodeProgram,
|
||||
};
|
||||
|
||||
export const SEMANTICA_EDGE_PROGRAM_CLASSES = {
|
||||
...DEFAULT_EDGE_PROGRAM_CLASSES,
|
||||
curve: EdgeCurveProgram,
|
||||
curvedArrow: EdgeCurvedArrowProgram,
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
export type GraphViewMode = "focused" | "full";
|
||||
export type GraphLayoutSource = "provided" | "carried" | "runtime";
|
||||
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
|
||||
export type GraphLoadPhase =
|
||||
| "bootstrapping"
|
||||
| "fetching_nodes"
|
||||
| "fetching_edges"
|
||||
| "computing_styling"
|
||||
| "hydrating_scene"
|
||||
| "stabilizing_layout"
|
||||
| "ready";
|
||||
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
||||
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
|
||||
export interface GraphCameraState {
|
||||
x: number;
|
||||
y: number;
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
export interface GraphInteractionState {
|
||||
hoveredNodeId: string | null;
|
||||
selectedNodeId: string;
|
||||
selectedEdgeId: string;
|
||||
focusedNodeId: string;
|
||||
activePath: string[];
|
||||
activePathEdgeIds: string[];
|
||||
viewMode: GraphViewMode;
|
||||
zoomTier: "overview" | "structure" | "inspection";
|
||||
isLayoutRunning: boolean;
|
||||
}
|
||||
|
||||
export type GraphEffectToggle =
|
||||
| "pathPulseEnabled"
|
||||
| "pathFlowEnabled"
|
||||
| "lensEnabled"
|
||||
| "temporalEmphasisEnabled"
|
||||
| "semanticRegionsEnabled"
|
||||
| "contoursEnabled"
|
||||
| "pathfindingEnabled"
|
||||
| "communitiesEnabled"
|
||||
| "centralityEnabled"
|
||||
| "legendEnabled"
|
||||
| "diagnosticsEnabled";
|
||||
|
||||
export interface GraphEffectsState {
|
||||
pathPulseEnabled: boolean;
|
||||
pathFlowEnabled: boolean;
|
||||
lensEnabled: boolean;
|
||||
temporalEmphasisEnabled: boolean;
|
||||
semanticRegionsEnabled: boolean;
|
||||
contoursEnabled: boolean;
|
||||
pathfindingEnabled: boolean;
|
||||
communitiesEnabled: boolean;
|
||||
centralityEnabled: boolean;
|
||||
legendEnabled: boolean;
|
||||
diagnosticsEnabled: boolean;
|
||||
lensMode: "neighborhood";
|
||||
effectQuality: "bounded";
|
||||
}
|
||||
|
||||
export interface GraphEffectAvailability {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
reason: string;
|
||||
detail?: string;
|
||||
visibleSegments?: number;
|
||||
segmentCap?: number;
|
||||
}
|
||||
|
||||
export interface GraphDiagnosticsSnapshot {
|
||||
interactionState: GraphInteractionState;
|
||||
activePluginIds: string[];
|
||||
openPanelIds: string[];
|
||||
effectsState: GraphEffectsState;
|
||||
effectAvailability: {
|
||||
pathPulse: GraphEffectAvailability;
|
||||
pathFlow: GraphEffectAvailability;
|
||||
lens: GraphEffectAvailability;
|
||||
temporalEmphasis: GraphEffectAvailability;
|
||||
semanticRegions: GraphEffectAvailability;
|
||||
contours: GraphEffectAvailability;
|
||||
pathfinding: GraphEffectAvailability;
|
||||
communities: GraphEffectAvailability;
|
||||
centrality: GraphEffectAvailability;
|
||||
legend: GraphEffectAvailability;
|
||||
diagnostics: GraphEffectAvailability;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GraphTemporalState {
|
||||
currentTime: Date | null;
|
||||
activeNodeCount: number | null;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
}
|
||||
|
||||
export interface GraphDirectedPathSnapshot {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
sourceId: string | null;
|
||||
targetId: string | null;
|
||||
path: string[];
|
||||
length: number | null;
|
||||
verifiedAgainstActivePath: boolean;
|
||||
}
|
||||
|
||||
export interface GraphCommunitySummary {
|
||||
communityId: string;
|
||||
nodeCount: number;
|
||||
visibleNodeCount: number;
|
||||
dominantSemanticGroup: string;
|
||||
color: string;
|
||||
anchorNodeId: string | null;
|
||||
anchorLabel: string;
|
||||
prominence: number;
|
||||
}
|
||||
|
||||
export interface GraphSemanticRegionSummary {
|
||||
semanticGroup: string;
|
||||
nodeCount: number;
|
||||
visibleNodeCount: number;
|
||||
color: string;
|
||||
anchorNodeId: string | null;
|
||||
anchorLabel: string;
|
||||
dominantCommunityId: string | null;
|
||||
prominence: number;
|
||||
}
|
||||
|
||||
export interface GraphCentralityNodeSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
semanticGroup: string;
|
||||
color: string;
|
||||
degree: number;
|
||||
betweenness: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface GraphOverviewBackboneSnapshot {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
edgeIds: string[];
|
||||
}
|
||||
|
||||
export interface GraphAnalyticsSnapshot {
|
||||
generatedAt: number;
|
||||
directedPath: GraphDirectedPathSnapshot;
|
||||
communities: {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
count: number;
|
||||
modularity: number | null;
|
||||
summaries: GraphCommunitySummary[];
|
||||
};
|
||||
centrality: {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
topNodes: GraphCentralityNodeSummary[];
|
||||
};
|
||||
semanticRegions: {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
summaries: GraphSemanticRegionSummary[];
|
||||
};
|
||||
overviewBackbone: GraphOverviewBackboneSnapshot;
|
||||
}
|
||||
|
||||
export interface ApiNode {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
properties: Record<string, unknown>;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiEdge {
|
||||
id: string;
|
||||
familyId: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
weight: number;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GraphLoadSummary {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
loadTimeMs: number;
|
||||
hasCoordinates?: boolean;
|
||||
layoutSource?: GraphLayoutSource;
|
||||
layoutReady?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphLoadProgress {
|
||||
phase: GraphLoadPhase;
|
||||
title: string;
|
||||
nodesLoaded: number;
|
||||
nodesTotal: number | null;
|
||||
edgesLoaded: number;
|
||||
edgesTotal: number | null;
|
||||
message: string;
|
||||
progressKind: GraphLoadProgressKind;
|
||||
loaded: number | null;
|
||||
total: number | null;
|
||||
showGraphBehind: boolean;
|
||||
stageIndex?: number;
|
||||
stageCount?: number;
|
||||
layoutSource?: GraphLayoutSource;
|
||||
layoutState?: GraphLayoutState;
|
||||
}
|
||||
|
||||
export interface GraphDataSnapshot {
|
||||
nodes: ApiNode[];
|
||||
edges: ApiEdge[];
|
||||
summary: GraphLoadSummary;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export interface GraphLayoutStatus {
|
||||
state: GraphLayoutState;
|
||||
source: GraphLayoutSource;
|
||||
hasCoordinates: boolean;
|
||||
layoutReady: boolean;
|
||||
displacement: number | null;
|
||||
elapsedMs: number;
|
||||
stableSamples: number;
|
||||
timedOut?: boolean;
|
||||
}
|
||||
|
||||
export type GraphPath = string[];
|
||||
|
||||
export interface GraphSelectedNodeState {
|
||||
id: string;
|
||||
label: string;
|
||||
content: string;
|
||||
nodeType: string;
|
||||
color?: string;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
neighborCount: number;
|
||||
}
|
||||
|
||||
export interface GraphSelectedEdgeState {
|
||||
id: string;
|
||||
familyId: string;
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
targetId: string;
|
||||
targetLabel: string;
|
||||
edgeType: string;
|
||||
weight: number;
|
||||
properties: Record<string, unknown>;
|
||||
provenanceCount: number;
|
||||
familySize: number;
|
||||
siblingCount: number;
|
||||
}
|
||||
|
||||
export interface GraphStageHandle {
|
||||
fitView: () => void;
|
||||
focusNode: (nodeId: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import type { ApiEdge, ApiNode, GraphDataSnapshot, GraphLoadProgress, GraphLayoutSource } from "./types";
|
||||
|
||||
interface NodeListResponse {
|
||||
nodes: ApiNode[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
next_cursor?: string | null;
|
||||
}
|
||||
|
||||
interface EdgeListResponse {
|
||||
edges: ApiEdge[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
next_cursor?: string | null;
|
||||
}
|
||||
|
||||
const PAGE_LIMIT = 1000;
|
||||
|
||||
async function fetchAllNodes(
|
||||
signal: AbortSignal,
|
||||
onProgress?: (progress: GraphLoadProgress) => void,
|
||||
): Promise<ApiNode[]> {
|
||||
let cursor: string | null = null;
|
||||
const collected: ApiNode[] = [];
|
||||
let total: number | null = null;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("/api/graph/nodes", window.location.origin);
|
||||
url.searchParams.set("limit", String(PAGE_LIMIT));
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: NodeListResponse = await response.json();
|
||||
if (!data.nodes?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
total = data.total ?? total;
|
||||
collected.push(...data.nodes);
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "fetching_nodes",
|
||||
progressKind: total ? "determinate" : "indeterminate",
|
||||
loaded: collected.length,
|
||||
total,
|
||||
nodesLoaded: collected.length,
|
||||
nodesTotal: total,
|
||||
edgesLoaded: 0,
|
||||
edgesTotal: null,
|
||||
message: total
|
||||
? `Loading nodes ${collected.length.toLocaleString()} of ${total.toLocaleString()}`
|
||||
: `Loading nodes ${collected.length.toLocaleString()}`,
|
||||
}));
|
||||
|
||||
if (!data.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = data.next_cursor;
|
||||
await yieldToMain();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
async function fetchAllEdges(
|
||||
signal: AbortSignal,
|
||||
nodeIds: Set<string>,
|
||||
nodeProgress: { loaded: number; total: number | null },
|
||||
onProgress?: (progress: GraphLoadProgress) => void,
|
||||
): Promise<ApiEdge[]> {
|
||||
let cursor: string | null = null;
|
||||
const collected: ApiEdge[] = [];
|
||||
const seenEdgeIds = new Set<string>();
|
||||
let total: number | null = null;
|
||||
let warnedOverTotal = false;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("/api/graph/edges", window.location.origin);
|
||||
url.searchParams.set("limit", String(PAGE_LIMIT));
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: EdgeListResponse = await response.json();
|
||||
if (!data.edges?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
total = data.total ?? total;
|
||||
const validEdges = data.edges.filter((edge) => {
|
||||
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
||||
return false;
|
||||
}
|
||||
if (seenEdgeIds.has(edge.id)) {
|
||||
return false;
|
||||
}
|
||||
seenEdgeIds.add(edge.id);
|
||||
return true;
|
||||
});
|
||||
collected.push(...validEdges);
|
||||
const safeLoaded = total ? Math.min(seenEdgeIds.size, total) : seenEdgeIds.size;
|
||||
if (!warnedOverTotal && total !== null && seenEdgeIds.size > total) {
|
||||
warnedOverTotal = true;
|
||||
console.warn("[graph-runtime] edge pagination returned more unique edge ids than total", {
|
||||
uniqueEdgesLoaded: seenEdgeIds.size,
|
||||
total,
|
||||
});
|
||||
}
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "fetching_edges",
|
||||
progressKind: total ? "determinate" : "indeterminate",
|
||||
loaded: safeLoaded,
|
||||
total,
|
||||
nodesLoaded: nodeProgress.loaded,
|
||||
nodesTotal: nodeProgress.total,
|
||||
edgesLoaded: safeLoaded,
|
||||
edgesTotal: total,
|
||||
message: total
|
||||
? `Loading edges ${safeLoaded.toLocaleString()} of ${total.toLocaleString()}`
|
||||
: `Loading edges ${safeLoaded.toLocaleString()}`,
|
||||
}));
|
||||
|
||||
if (!data.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = data.next_cursor;
|
||||
await yieldToMain();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function yieldToMain(): Promise<void> {
|
||||
if ("scheduler" in window && typeof (window as Window & { scheduler?: { yield?: () => Promise<void> } }).scheduler?.yield === "function") {
|
||||
return (window as Window & { scheduler: { yield: () => Promise<void> } }).scheduler.yield();
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function hasUsableCoordinate(value: number | null | undefined): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
interface UseGraphDataOptions {
|
||||
enabled?: boolean;
|
||||
onProgress?: (progress: GraphLoadProgress) => void;
|
||||
}
|
||||
|
||||
export function useGraphData(options: UseGraphDataOptions = {}) {
|
||||
const { enabled = true, onProgress } = options;
|
||||
|
||||
return useQuery<GraphDataSnapshot>({
|
||||
queryKey: ["graph", "runtime-snapshot"],
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
queryFn: async ({ signal }): Promise<GraphDataSnapshot> => {
|
||||
const startedAt = performance.now();
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "bootstrapping",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: 0,
|
||||
nodesTotal: null,
|
||||
edgesLoaded: 0,
|
||||
edgesTotal: null,
|
||||
message: "Preparing graph session",
|
||||
}));
|
||||
|
||||
const nodes = await fetchAllNodes(signal, onProgress);
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edges = await fetchAllEdges(
|
||||
signal,
|
||||
nodeIds,
|
||||
{ loaded: nodes.length, total: nodes.length },
|
||||
onProgress,
|
||||
);
|
||||
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "hydrating_scene",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: nodes.length,
|
||||
nodesTotal: nodes.length,
|
||||
edgesLoaded: edges.length,
|
||||
edgesTotal: edges.length,
|
||||
message: "Preparing graph runtime snapshot",
|
||||
}));
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
summary: {
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length,
|
||||
loadTimeMs: Math.round(performance.now() - startedAt),
|
||||
hasCoordinates: nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)),
|
||||
layoutSource: (nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y))
|
||||
? "provided"
|
||||
: "runtime") as GraphLayoutSource,
|
||||
layoutReady: nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)),
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReloadGraphData() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: ["graph", "runtime-snapshot"] });
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { batchMergeEdges, batchMergeNodes, clearGraph } from "../../store/graphStore";
|
||||
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
||||
import { curveGroupForPair, pairRegistryKey } from "../../store/edgePairKeys.js";
|
||||
import {
|
||||
GRAPH_THEME,
|
||||
clamp,
|
||||
darkenHex,
|
||||
hashString,
|
||||
withAlpha,
|
||||
type GraphBadgeKind,
|
||||
type GraphEdgeVariant,
|
||||
type GraphLabelVisibilityPolicy,
|
||||
type GraphNodeShapeVariant,
|
||||
} from "./graphTheme";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import type { GraphLoadProgress, GraphLoadSummary } from "./types";
|
||||
|
||||
const SEMANTIC_COLOR_FIELDS = [
|
||||
"community",
|
||||
"cluster",
|
||||
"module",
|
||||
"group",
|
||||
"category",
|
||||
"domain",
|
||||
"layer",
|
||||
"source",
|
||||
"nodeType",
|
||||
] as const;
|
||||
|
||||
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||
|
||||
function getSemanticFieldValue(attributes: NodeAttributes, field: (typeof SEMANTIC_COLOR_FIELDS)[number]): string | null {
|
||||
if (field === "nodeType") {
|
||||
const value = attributes.nodeType;
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
const value = attributes.properties?.[field];
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function normalizedEntropy(counts: number[], total: number): number {
|
||||
if (counts.length <= 1 || total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let entropy = 0;
|
||||
for (const count of counts) {
|
||||
const probability = count / total;
|
||||
entropy -= probability * Math.log(probability);
|
||||
}
|
||||
|
||||
return entropy / Math.log(counts.length);
|
||||
}
|
||||
|
||||
function chooseColorAccessor(
|
||||
nodes: Array<{ id: string; attributes: NodeAttributes }>,
|
||||
): (nodeId: string, attributes: NodeAttributes) => string {
|
||||
let bestField: (typeof SEMANTIC_COLOR_FIELDS)[number] | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const field of SEMANTIC_COLOR_FIELDS) {
|
||||
const counts = new Map<string, number>();
|
||||
let covered = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const value = getSemanticFieldValue(node.attributes, field);
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
covered += 1;
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const uniqueCount = counts.size;
|
||||
if (covered === 0 || uniqueCount <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const countValues = [...counts.values()];
|
||||
const coverage = covered / nodes.length;
|
||||
const dominantRatio = Math.max(...countValues) / covered;
|
||||
const entropy = normalizedEntropy(countValues, covered);
|
||||
const diversity = Math.min(uniqueCount, GRAPH_THEME.palette.semantic.length) / GRAPH_THEME.palette.semantic.length;
|
||||
const score = entropy * 0.65 + diversity * 0.2 + coverage * 0.15;
|
||||
|
||||
const isInformative =
|
||||
coverage >= 0.45 &&
|
||||
entropy >= 0.45 &&
|
||||
dominantRatio <= 0.88;
|
||||
|
||||
if (!isInformative) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestField = field;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestField) {
|
||||
return (_nodeId: string, attributes: NodeAttributes) =>
|
||||
getSemanticFieldValue(attributes, bestField) ?? structuralColorKey(_nodeId, attributes);
|
||||
}
|
||||
|
||||
return (nodeId: string, attributes: NodeAttributes) => structuralColorKey(nodeId, attributes);
|
||||
}
|
||||
|
||||
function structuralColorKey(nodeId: string, attributes: NodeAttributes): string {
|
||||
const shard = hashString(nodeId) % GRAPH_THEME.palette.semantic.length;
|
||||
return `${attributes.nodeType || "entity"}:${shard}`;
|
||||
}
|
||||
|
||||
function readFiniteCoordinate(value: unknown): number | null {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function seededUnit(value: string): number {
|
||||
return (hashString(value) % 10000) / 10000;
|
||||
}
|
||||
|
||||
function buildClusterSeedPositions(
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
semanticGroup: string;
|
||||
priority: number;
|
||||
}>,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const grouped = new Map<string, Array<{ id: string; priority: number }>>();
|
||||
nodes.forEach((node) => {
|
||||
const entry = grouped.get(node.semanticGroup);
|
||||
if (entry) {
|
||||
entry.push({ id: node.id, priority: node.priority });
|
||||
} else {
|
||||
grouped.set(node.semanticGroup, [{ id: node.id, priority: node.priority }]);
|
||||
}
|
||||
});
|
||||
|
||||
const groupEntries = [...grouped.entries()]
|
||||
.sort((left, right) => {
|
||||
const countDelta = right[1].length - left[1].length;
|
||||
if (countDelta !== 0) {
|
||||
return countDelta;
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
});
|
||||
|
||||
const groupCenters = new Map<string, { x: number; y: number; spread: number }>();
|
||||
groupEntries.forEach(([group, members], index) => {
|
||||
const angle = index * 2.399963229728653;
|
||||
const radius = 170 + Math.sqrt(index + 1) * 195;
|
||||
const spread = 72 + Math.sqrt(members.length) * 16;
|
||||
groupCenters.set(group, {
|
||||
x: Math.cos(angle) * radius * 1.14,
|
||||
y: Math.sin(angle) * radius * 0.84,
|
||||
spread,
|
||||
});
|
||||
});
|
||||
|
||||
const seeded = new Map<string, { x: number; y: number }>();
|
||||
groupEntries.forEach(([group, members]) => {
|
||||
const center = groupCenters.get(group);
|
||||
if (!center) {
|
||||
return;
|
||||
}
|
||||
|
||||
members
|
||||
.sort((left, right) => {
|
||||
if (right.priority !== left.priority) {
|
||||
return right.priority - left.priority;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
})
|
||||
.forEach((member, index) => {
|
||||
const angle = index * 2.399963229728653 + seededUnit(`${group}:${member.id}:angle`) * 0.72;
|
||||
const radial = Math.sqrt((index + 0.5) / Math.max(members.length, 1)) * center.spread;
|
||||
const jitterX = (seededUnit(`${member.id}:jx`) - 0.5) * center.spread * 0.22;
|
||||
const jitterY = (seededUnit(`${member.id}:jy`) - 0.5) * center.spread * 0.18;
|
||||
seeded.set(member.id, {
|
||||
x: center.x + Math.cos(angle) * radial + jitterX,
|
||||
y: center.y + Math.sin(angle) * radial * 0.86 + jitterY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function getProvenanceCount(properties: Record<string, unknown>): number {
|
||||
return PROVENANCE_KEYS.reduce(
|
||||
(count, key) => (properties[key] !== undefined && properties[key] !== null ? count + 1 : count),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNodeVariantMetadata(
|
||||
baseColor: string,
|
||||
sizeRatio: number,
|
||||
hasTemporalBounds: boolean,
|
||||
provenanceCount: number,
|
||||
): Pick<
|
||||
NodeAttributes,
|
||||
"nodeVariant" | "nodeShapeVariant" | "badgeKind" | "badgeCount" | "ringColor" | "haloColor" | "labelVisibilityPolicy"
|
||||
> {
|
||||
let nodeShapeVariant: GraphNodeShapeVariant = "default";
|
||||
let badgeKind: GraphBadgeKind | undefined;
|
||||
let badgeCount: number | undefined;
|
||||
|
||||
if (hasTemporalBounds) {
|
||||
nodeShapeVariant = "temporal";
|
||||
badgeKind = "temporal";
|
||||
} else if (provenanceCount > 0) {
|
||||
nodeShapeVariant = "provenance";
|
||||
badgeKind = "provenance";
|
||||
badgeCount = provenanceCount;
|
||||
}
|
||||
|
||||
let labelVisibilityPolicy: GraphLabelVisibilityPolicy = "none";
|
||||
if (sizeRatio >= 0.86) {
|
||||
labelVisibilityPolicy = "always";
|
||||
} else if (badgeKind) {
|
||||
labelVisibilityPolicy = "local";
|
||||
} else if (sizeRatio >= 0.56) {
|
||||
labelVisibilityPolicy = "priority";
|
||||
}
|
||||
|
||||
return {
|
||||
nodeVariant: nodeShapeVariant,
|
||||
nodeShapeVariant,
|
||||
badgeKind,
|
||||
badgeCount,
|
||||
ringColor: GRAPH_THEME.nodes.selectedRing.color,
|
||||
haloColor: withAlpha(baseColor, 0.38),
|
||||
labelVisibilityPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEdgeVariantMetadata(
|
||||
edge: ApiEdge,
|
||||
sourcePriority: number,
|
||||
targetPriority: number,
|
||||
isBidirectional: boolean,
|
||||
): Pick<
|
||||
EdgeAttributes,
|
||||
"edgeVariant" | "arrowVisibilityPolicy" | "relationshipStrength" | "isParallelPair" | "parallelIndex" | "parallelCount"
|
||||
> {
|
||||
const relationshipStrength = clamp(
|
||||
0.12,
|
||||
0.18 + Math.log(Math.max(Number(edge.weight) || 1, 1)) / Math.log(12),
|
||||
1,
|
||||
);
|
||||
|
||||
let edgeVariant: GraphEdgeVariant = "line";
|
||||
if (isBidirectional) {
|
||||
edgeVariant = "bidirectionalCurve";
|
||||
} else if (Math.max(sourcePriority, targetPriority, relationshipStrength) >= 0.58) {
|
||||
edgeVariant = "directional";
|
||||
}
|
||||
|
||||
return {
|
||||
edgeVariant,
|
||||
arrowVisibilityPolicy: edgeVariant === "line" ? "hidden" : "contextual",
|
||||
relationshipStrength,
|
||||
isParallelPair: false,
|
||||
parallelIndex: 0,
|
||||
parallelCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiNode {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
properties: Record<string, unknown>;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
}
|
||||
|
||||
interface ApiEdge {
|
||||
id: string;
|
||||
familyId: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
weight: number;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface NodeListResponse {
|
||||
nodes: ApiNode[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
next_cursor?: string | null;
|
||||
}
|
||||
|
||||
interface EdgeListResponse {
|
||||
edges: ApiEdge[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
next_cursor?: string | null;
|
||||
}
|
||||
|
||||
const PAGE_LIMIT = 1000;
|
||||
|
||||
async function fetchAllNodes(
|
||||
signal: AbortSignal,
|
||||
onProgress?: (progress: GraphLoadProgress) => void,
|
||||
): Promise<ApiNode[]> {
|
||||
let cursor: string | null = null;
|
||||
const collected: ApiNode[] = [];
|
||||
let total: number | null = null;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("/api/graph/nodes", window.location.origin);
|
||||
url.searchParams.set("limit", String(PAGE_LIMIT));
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: NodeListResponse = await response.json();
|
||||
if (!data.nodes?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
total = data.total ?? total;
|
||||
collected.push(...data.nodes);
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "fetching_nodes",
|
||||
progressKind: total ? "determinate" : "indeterminate",
|
||||
loaded: collected.length,
|
||||
total,
|
||||
nodesLoaded: collected.length,
|
||||
nodesTotal: total,
|
||||
edgesLoaded: 0,
|
||||
edgesTotal: null,
|
||||
message: total
|
||||
? `Loading nodes ${collected.length.toLocaleString()} of ${total.toLocaleString()}`
|
||||
: `Loading nodes ${collected.length.toLocaleString()}`,
|
||||
}));
|
||||
|
||||
if (!data.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = data.next_cursor;
|
||||
await yieldToMain();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
async function fetchAllEdges(
|
||||
signal: AbortSignal,
|
||||
nodeIds: Set<string>,
|
||||
nodeProgress: { loaded: number; total: number | null },
|
||||
onProgress?: (progress: GraphLoadProgress) => void,
|
||||
): Promise<ApiEdge[]> {
|
||||
let cursor: string | null = null;
|
||||
const collected: ApiEdge[] = [];
|
||||
const seenEdgeIds = new Set<string>();
|
||||
let total: number | null = null;
|
||||
let warnedOverTotal = false;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("/api/graph/edges", window.location.origin);
|
||||
url.searchParams.set("limit", String(PAGE_LIMIT));
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: EdgeListResponse = await response.json();
|
||||
if (!data.edges?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
total = data.total ?? total;
|
||||
const validEdges = data.edges.filter((edge) => {
|
||||
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
||||
return false;
|
||||
}
|
||||
if (seenEdgeIds.has(edge.id)) {
|
||||
return false;
|
||||
}
|
||||
seenEdgeIds.add(edge.id);
|
||||
return true;
|
||||
});
|
||||
collected.push(...validEdges);
|
||||
const safeLoaded = total ? Math.min(seenEdgeIds.size, total) : seenEdgeIds.size;
|
||||
if (!warnedOverTotal && total !== null && seenEdgeIds.size > total) {
|
||||
warnedOverTotal = true;
|
||||
console.warn("[graph-load] edge pagination returned more unique edge ids than total", {
|
||||
uniqueEdgesLoaded: seenEdgeIds.size,
|
||||
total,
|
||||
});
|
||||
}
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "fetching_edges",
|
||||
progressKind: total ? "determinate" : "indeterminate",
|
||||
loaded: safeLoaded,
|
||||
total,
|
||||
nodesLoaded: nodeProgress.loaded,
|
||||
nodesTotal: nodeProgress.total,
|
||||
edgesLoaded: safeLoaded,
|
||||
edgesTotal: total,
|
||||
message: total
|
||||
? `Loading edges ${safeLoaded.toLocaleString()} of ${total.toLocaleString()}`
|
||||
: `Loading edges ${safeLoaded.toLocaleString()}`,
|
||||
}));
|
||||
|
||||
if (!data.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = data.next_cursor;
|
||||
await yieldToMain();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function yieldToMain(): Promise<void> {
|
||||
if ("scheduler" in window && typeof (window as Window & { scheduler?: { yield?: () => Promise<void> } }).scheduler?.yield === "function") {
|
||||
return (window as Window & { scheduler: { yield: () => Promise<void> } }).scheduler.yield();
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
interface UseLoadGraphOptions {
|
||||
enabled?: boolean;
|
||||
onGraphReady?: (summary: GraphLoadSummary) => void;
|
||||
onProgress?: (progress: GraphLoadProgress) => void;
|
||||
}
|
||||
|
||||
export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
const { enabled = true, onGraphReady, onProgress } = options;
|
||||
|
||||
return useQuery<GraphLoadSummary>({
|
||||
queryKey: ["graph", "full-load"],
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
retry: 0,
|
||||
queryFn: async ({ signal }): Promise<GraphLoadSummary> => {
|
||||
const startedAt = performance.now();
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "bootstrapping",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: 0,
|
||||
nodesTotal: null,
|
||||
edgesLoaded: 0,
|
||||
edgesTotal: null,
|
||||
message: "Preparing graph session",
|
||||
}));
|
||||
|
||||
const fetchedNodes = await fetchAllNodes(signal, onProgress);
|
||||
const nodeIds = new Set(fetchedNodes.map((node) => node.id));
|
||||
const fetchedEdges = await fetchAllEdges(
|
||||
signal,
|
||||
nodeIds,
|
||||
{ loaded: fetchedNodes.length, total: fetchedNodes.length },
|
||||
onProgress,
|
||||
);
|
||||
|
||||
const degreeByNode = new Map<string, number>();
|
||||
for (const nodeId of nodeIds) {
|
||||
degreeByNode.set(nodeId, 0);
|
||||
}
|
||||
for (const edge of fetchedEdges) {
|
||||
degreeByNode.set(edge.source, (degreeByNode.get(edge.source) ?? 0) + 1);
|
||||
degreeByNode.set(edge.target, (degreeByNode.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const maxDegree = Math.max(...degreeByNode.values(), 1);
|
||||
const draftAttributes = fetchedNodes.map((node) => ({
|
||||
id: node.id,
|
||||
attributes: {
|
||||
label: node.content || node.id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
nodeType: node.type,
|
||||
content: node.content,
|
||||
valid_from: node.valid_from,
|
||||
valid_until: node.valid_until,
|
||||
properties: node.properties,
|
||||
} as NodeAttributes,
|
||||
}));
|
||||
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "computing_styling",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: fetchedNodes.length,
|
||||
nodesTotal: fetchedNodes.length,
|
||||
edgesLoaded: fetchedEdges.length,
|
||||
edgesTotal: fetchedEdges.length,
|
||||
message: "Applying semantic color, sizing, and structural styling",
|
||||
}));
|
||||
|
||||
const colorAccessor = chooseColorAccessor(draftAttributes);
|
||||
const nodePriorityById = new Map<string, number>();
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const degree = degreeByNode.get(nodeId) ?? 0;
|
||||
const sizeRatio = Math.log(degree + 1) / Math.log(maxDegree + 1);
|
||||
nodePriorityById.set(nodeId, sizeRatio);
|
||||
}
|
||||
|
||||
const semanticKeyByNodeId = new Map<string, string>();
|
||||
draftAttributes.forEach(({ id, attributes }) => {
|
||||
semanticKeyByNodeId.set(id, colorAccessor(id, attributes));
|
||||
});
|
||||
|
||||
const providedCoordinateCount = fetchedNodes.reduce((count, node) => {
|
||||
const properties = node.properties as Record<string, unknown>;
|
||||
return readFiniteCoordinate(properties?.x) !== null && readFiniteCoordinate(properties?.y) !== null
|
||||
? count + 1
|
||||
: count;
|
||||
}, 0);
|
||||
const coordinateCoverage = fetchedNodes.length > 0 ? providedCoordinateCount / fetchedNodes.length : 0;
|
||||
const useProvidedCoordinates = coordinateCoverage >= 0.92;
|
||||
const seededPositions = useProvidedCoordinates
|
||||
? null
|
||||
: buildClusterSeedPositions(
|
||||
draftAttributes.map(({ id, attributes }) => ({
|
||||
id,
|
||||
semanticGroup: semanticKeyByNodeId.get(id) ?? structuralColorKey(id, attributes),
|
||||
priority: nodePriorityById.get(id) ?? 0,
|
||||
})),
|
||||
);
|
||||
|
||||
const nodesToMerge = draftAttributes.map(({ id, attributes }) => {
|
||||
const semanticGroup = semanticKeyByNodeId.get(id) ?? colorAccessor(id, attributes);
|
||||
const colorIndex = hashString(semanticGroup) % GRAPH_THEME.palette.semantic.length;
|
||||
const baseColor = GRAPH_THEME.palette.semantic[colorIndex];
|
||||
const sizeRatio = nodePriorityById.get(id) ?? 0;
|
||||
const dynamicSize = clamp(1.8, 1.8 + 8.8 * sizeRatio, 11.8);
|
||||
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
|
||||
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
|
||||
const properties = attributes.properties as Record<string, unknown>;
|
||||
const providedX = readFiniteCoordinate(properties?.x);
|
||||
const providedY = readFiniteCoordinate(properties?.y);
|
||||
const seededPosition = seededPositions?.get(id);
|
||||
const x = useProvidedCoordinates
|
||||
? providedX ?? 0
|
||||
: providedX ?? seededPosition?.x ?? 0;
|
||||
const y = useProvidedCoordinates
|
||||
? providedY ?? 0
|
||||
: providedY ?? seededPosition?.y ?? 0;
|
||||
return {
|
||||
id,
|
||||
attributes: {
|
||||
...attributes,
|
||||
x,
|
||||
y,
|
||||
semanticGroup,
|
||||
color: baseColor,
|
||||
baseColor,
|
||||
mutedColor: withAlpha(baseColor, GRAPH_THEME.nodes.mutedAlpha),
|
||||
glowColor: withAlpha(baseColor, 0.24),
|
||||
size: dynamicSize,
|
||||
baseSize: dynamicSize,
|
||||
visualPriority: sizeRatio,
|
||||
labelPriority: sizeRatio,
|
||||
strokeColor: darkenHex(baseColor, 112),
|
||||
borderColor: darkenHex(baseColor, 112),
|
||||
borderSize: 0.72,
|
||||
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
|
||||
} as NodeAttributes,
|
||||
};
|
||||
});
|
||||
|
||||
const edgeKeys = new Set(fetchedEdges.map((edge) => pairRegistryKey(edge.source, edge.target)));
|
||||
const parallelCounts = new Map<string, number>();
|
||||
const familyCounts = new Map<string, number>();
|
||||
fetchedEdges.forEach((edge) => {
|
||||
const pairKey = pairRegistryKey(edge.source, edge.target);
|
||||
parallelCounts.set(pairKey, (parallelCounts.get(pairKey) ?? 0) + 1);
|
||||
familyCounts.set(edge.familyId, (familyCounts.get(edge.familyId) ?? 0) + 1);
|
||||
});
|
||||
const parallelOffsets = new Map<string, number>();
|
||||
|
||||
const edgesToMerge = fetchedEdges.map((edge) => {
|
||||
const sourcePriority = nodePriorityById.get(edge.source) ?? 0;
|
||||
const targetPriority = nodePriorityById.get(edge.target) ?? 0;
|
||||
const isBidirectional = edgeKeys.has(pairRegistryKey(edge.target, edge.source));
|
||||
const pairKey = pairRegistryKey(edge.source, edge.target);
|
||||
const parallelIndex = parallelOffsets.get(pairKey) ?? 0;
|
||||
parallelOffsets.set(pairKey, parallelIndex + 1);
|
||||
const parallelCount = parallelCounts.get(pairKey) ?? 1;
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
familyId: edge.familyId,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
attributes: {
|
||||
edgeId: edge.id,
|
||||
familyId: edge.familyId,
|
||||
sourceId: edge.source,
|
||||
targetId: edge.target,
|
||||
weight: edge.weight,
|
||||
edgeType: edge.type,
|
||||
properties: edge.properties,
|
||||
size: clamp(0.18, 0.22 + Math.sqrt(Math.max(Number(edge.weight) || 1, 1)) * 0.2, 0.88),
|
||||
baseSize: clamp(0.18, 0.22 + Math.sqrt(Math.max(Number(edge.weight) || 1, 1)) * 0.2, 0.88),
|
||||
color: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
baseColor: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
|
||||
visualPriority: Math.max(sourcePriority, targetPriority),
|
||||
isBidirectional,
|
||||
edgeFamily: isBidirectional ? "bidirectional" : "line",
|
||||
curveGroup: curveGroupForPair(edge.source, edge.target),
|
||||
type: "line",
|
||||
isParallelPair: parallelCount > 1,
|
||||
parallelIndex,
|
||||
parallelCount,
|
||||
familySize: familyCounts.get(edge.familyId) ?? 1,
|
||||
...resolveEdgeVariantMetadata(edge, sourcePriority, targetPriority, isBidirectional),
|
||||
} as EdgeAttributes,
|
||||
};
|
||||
});
|
||||
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "hydrating_scene",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: nodesToMerge.length,
|
||||
nodesTotal: nodesToMerge.length,
|
||||
edgesLoaded: edgesToMerge.length,
|
||||
edgesTotal: edgesToMerge.length,
|
||||
message: "Preparing renderer and hydrating graph scene",
|
||||
}));
|
||||
|
||||
try {
|
||||
clearGraph();
|
||||
} catch (error) {
|
||||
console.error("[graph-load] clearGraph failed", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
batchMergeNodes(nodesToMerge);
|
||||
} catch (error) {
|
||||
console.error("[graph-load] batchMergeNodes failed", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
batchMergeEdges(edgesToMerge);
|
||||
} catch (error) {
|
||||
console.error("[graph-load] batchMergeEdges failed", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
nodeCount: nodesToMerge.length,
|
||||
edgeCount: edgesToMerge.length,
|
||||
loadTimeMs: Math.round(performance.now() - startedAt),
|
||||
hasCoordinates: useProvidedCoordinates,
|
||||
layoutSource: useProvidedCoordinates ? "provided" : "runtime",
|
||||
layoutReady: useProvidedCoordinates,
|
||||
} satisfies GraphLoadSummary;
|
||||
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: summary.layoutReady ? "ready" : "stabilizing_layout",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: summary.nodeCount,
|
||||
nodesTotal: summary.nodeCount,
|
||||
edgesLoaded: summary.edgeCount,
|
||||
edgesTotal: summary.edgeCount,
|
||||
message: summary.layoutReady ? "Graph ready" : "Settling runtime layout",
|
||||
showGraphBehind: !summary.layoutReady,
|
||||
layoutSource: summary.layoutSource,
|
||||
layoutState: summary.layoutReady ? "interactive" : "bootstrapping",
|
||||
}));
|
||||
|
||||
onGraphReady?.(summary);
|
||||
return summary;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReloadGraph() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { UploadCloud, Download, FileJson, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
.dropzone {
|
||||
border: 2px dashed rgba(88,166,255,0.4);
|
||||
border-radius: 12px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropzone:hover, .dropzone.active {
|
||||
border-color: #58a6ff;
|
||||
background: rgba(88,166,255,0.05);
|
||||
}
|
||||
.btn-primary {
|
||||
background: #238636;
|
||||
color: #fff;
|
||||
border: 1px solid rgba(240,246,252,0.1);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2ea043;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.toast {
|
||||
animation: slideUp 0.3s ease-out forwards;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`;
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function ImportExportWorkspace() {
|
||||
// Import State
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// Export State
|
||||
const [exportFormat, setExportFormat] = useState<"json" | "csv">("json");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Toasts
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const showToast = (type: "success" | "error", text: string) => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, type, text }]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setFile(acceptedFiles[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/json': ['.json'],
|
||||
'text/csv': ['.csv']
|
||||
},
|
||||
maxFiles: 1
|
||||
});
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return;
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/api/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Import failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
showToast("success", `Imported ${data.nodes_imported} nodes and ${data.edges_imported} edges!`);
|
||||
setFile(null);
|
||||
} catch (err: any) {
|
||||
showToast("error", err.message || "An error occurred during import");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const res = await fetch("/api/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ format: exportFormat })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Export failed");
|
||||
}
|
||||
|
||||
// Handle file download
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
// Provide a default extension based on format
|
||||
a.download = `semantica_export.${exportFormat}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showToast("success", "Export complete! Your download should begin shortly.");
|
||||
} catch (err: any) {
|
||||
showToast("error", err.message || "An error occurred during export");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 32, gap: 24, boxSizing: "border-box", overflowY: "auto" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 8px 0", color: "#fff", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<UploadCloud size={28} color="#58a6ff" /> Data Import / Export
|
||||
</h1>
|
||||
<p style={{ margin: 0, color: "#8b949e" }}>Ingest new graph datasets or extract the current knowledge base.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
|
||||
|
||||
{/* IMPORT PANEL */}
|
||||
<div className="glass-panel" style={{ flex: "1 1 400px", borderRadius: 12, padding: 32, display: "flex", flexDirection: "column" }}>
|
||||
<h2 style={{ margin: "0 0 24px 0", color: "#58a6ff", fontSize: 20, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<UploadCloud size={20} /> Import Entities & Relations
|
||||
</h2>
|
||||
|
||||
<div {...getRootProps()} className={`dropzone ${isDragActive ? 'active' : ''}`} style={{ padding: 48, textAlign: "center", marginBottom: 24, flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
||||
<input {...getInputProps()} />
|
||||
{file ? (
|
||||
<>
|
||||
{file.name.endsWith(".json") ? <FileJson size={48} color="#3fb950" style={{ marginBottom: 16 }} /> : <FileText size={48} color="#3fb950" style={{ marginBottom: 16 }} />}
|
||||
<p style={{ color: "#fff", fontWeight: 600, margin: "0 0 8px 0" }}>{file.name}</p>
|
||||
<p style={{ color: "#8b949e", fontSize: 13, margin: 0 }}>{(file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud size={48} color="#58a6ff" style={{ marginBottom: 16, opacity: 0.8 }} />
|
||||
<p style={{ color: "#c9d1d9", fontSize: 16, fontWeight: 500, margin: "0 0 8px 0" }}>Drag & drop your file here</p>
|
||||
<p style={{ color: "#8b949e", fontSize: 13, margin: 0 }}>Supports .json and .csv formats</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleImport}
|
||||
disabled={!file || isUploading}
|
||||
style={{ width: "100%", padding: "12px 24px", borderRadius: 8, fontSize: 16, fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, cursor: (!file || isUploading) ? "not-allowed" : "pointer", border: "none" }}
|
||||
>
|
||||
{isUploading ? <Loader2 size={20} className="animate-spin" /> : <UploadCloud size={20} />}
|
||||
{isUploading ? "Uploading..." : "Upload to Graph"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* EXPORT PANEL */}
|
||||
<div className="glass-panel" style={{ flex: "1 1 400px", borderRadius: 12, padding: 32, display: "flex", flexDirection: "column" }}>
|
||||
<h2 style={{ margin: "0 0 24px 0", color: "#d2a8ff", fontSize: 20, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Download size={20} /> Export Graph Snapshot
|
||||
</h2>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 14, fontWeight: 500 }}>Export Format</label>
|
||||
<div style={{ position: "relative", marginBottom: 32 }}>
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={e => setExportFormat(e.target.value as "json" | "csv")}
|
||||
style={{ width: "100%", appearance: "none", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(88,166,255,0.3)", color: "#fff", padding: "12px 16px", borderRadius: 8, fontSize: 15, cursor: "pointer", outline: "none" }}
|
||||
>
|
||||
<option value="json" style={{ background: "#0d1117" }}>JSON (Full Graph Dictionary)</option>
|
||||
<option value="csv" style={{ background: "#0d1117" }}>CSV (Tabular Dump)</option>
|
||||
</select>
|
||||
<div style={{ position: "absolute", right: 16, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}>
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.41 0.589966L6 5.16997L10.59 0.589966L12 1.99997L6 7.99997L0 1.99997L1.41 0.589966Z" fill="#8b949e"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: "rgba(0,0,0,0.2)", padding: 20, borderRadius: 8, border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<h4 style={{ color: "#c9d1d9", margin: "0 0 8px 0", fontSize: 14 }}>Export Details</h4>
|
||||
<p style={{ color: "#8b949e", fontSize: 13, margin: 0, lineHeight: 1.5 }}>
|
||||
{exportFormat === "json"
|
||||
? "Exports the entire graph including all node properties, edge weights, and complete entity metadata into a standardized JSON payload."
|
||||
: "Exports a flattened CSV tabular representation of all nodes and edges. Complex nested properties will be omitted or stringified."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={{ width: "100%", background: "#1f6feb", color: "#fff", padding: "12px 24px", borderRadius: 8, fontSize: 16, fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, cursor: isExporting ? "not-allowed" : "pointer", border: "1px solid rgba(240,246,252,0.1)", transition: "background 0.2s" }}
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
onMouseOver={e => { if(!isExporting) (e.currentTarget.style.background = "#388bfd") }}
|
||||
onMouseOut={e => { if(!isExporting) (e.currentTarget.style.background = "#1f6feb") }}
|
||||
>
|
||||
{isExporting ? <Loader2 size={20} className="animate-spin" /> : <Download size={20} />}
|
||||
{isExporting ? "Preparing Extract..." : "Download Graph Extract"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<div style={{ position: "fixed", bottom: 32, right: 32, display: "flex", flexDirection: "column", gap: 12, zIndex: 1000 }}>
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className="toast" style={{
|
||||
display: "flex", alignItems: "center", gap: 12, padding: "16px 20px", borderRadius: 8,
|
||||
background: toast.type === 'success' ? '#1b4a24' : '#571822',
|
||||
border: `1px solid ${toast.type === 'success' ? 'rgba(63, 185, 80, 0.4)' : 'rgba(248, 81, 73, 0.4)'}`,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.5)"
|
||||
}}>
|
||||
{toast.type === 'success' ? <CheckCircle2 color="#3fb950" size={20}/> : <AlertCircle color="#f85149" size={20}/>}
|
||||
<span style={{ color: "#fff", fontSize: 14, fontWeight: 500 }}>{toast.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* src/workspaces/LineageWorkspace/LineageDiagram.tsx
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { ReactFlow, Background, Controls } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
const THEME_CSS = `
|
||||
.react-flow { background: #0d1117; }
|
||||
.react-flow__node-group {
|
||||
background: rgba(88,166,255,0.05);
|
||||
border: 1px dashed rgba(88,166,255,0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.react-flow__node-default {
|
||||
background: #161b22;
|
||||
color: #c9d1d9;
|
||||
border: 1px solid rgba(88,166,255,0.3);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function LineageDiagram() {
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [edges, setEdges] = useState<any[]>([]);
|
||||
const [searchId, setSearchId] = useState("");
|
||||
const [activeId, setActiveId] = useState("");
|
||||
|
||||
const downloadReport = async (format: "json" | "markdown") => {
|
||||
if (!activeId) return;
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(activeId)}&format=${format}`);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${activeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(anchor);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const xLanes = [
|
||||
{ id: "group_agent", type: "group", position: { x: 50, y: 50 }, style: { width: 800, height: 120 } },
|
||||
{ id: "group_activity", type: "group", position: { x: 50, y: 200 }, style: { width: 800, height: 120 } },
|
||||
{ id: "group_entity", type: "group", position: { x: 50, y: 350 }, style: { width: 800, height: 120 } }
|
||||
];
|
||||
|
||||
const fetchLineage = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/provenance?node_id=" + encodeURIComponent(activeId));
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`HTTP ${res.status}: API Route missing or failed.`, text.substring(0, 100));
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
console.error("Backend returned non-JSON response (likely an HTML fallback). Check FastAPI routing.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const counters: Record<string, number> = { "group_agent": 0, "group_activity": 0, "group_entity": 0 };
|
||||
|
||||
const mappedNodes = data.nodes.map((n: any) => {
|
||||
const c = counters[n.parent_id] || 0;
|
||||
counters[n.parent_id] = c + 1;
|
||||
return {
|
||||
id: n.id,
|
||||
data: { label: n.label + "\\n(" + n.prov_type + ")" },
|
||||
position: { x: 50 + c * 180, y: 30 },
|
||||
parentId: n.parent_id,
|
||||
extent: "parent",
|
||||
type: "default"
|
||||
};
|
||||
});
|
||||
|
||||
const mappedEdges = data.edges.map((e: any) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
animated: true,
|
||||
style: { stroke: "#58a6ff" }
|
||||
}));
|
||||
|
||||
setNodes([...xLanes, ...mappedNodes]);
|
||||
setEdges(mappedEdges);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
fetchLineage();
|
||||
}, [activeId]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", position: "relative", background: "#0d1117" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Top Bar Navigation */}
|
||||
<div style={{ position: "absolute", top: 16, left: 16, zIndex: 10, display: "flex", gap: "12px", alignItems: "center" }}>
|
||||
<div style={{ background: "rgba(13,17,23,0.8)", padding: "4px 8px", borderRadius: 4, color: "#fff", fontWeight: 600, border: "1px solid rgba(255,255,255,0.1)", pointerEvents: "none" }}>
|
||||
PROV-O Lineage
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Node ID..."
|
||||
value={searchId}
|
||||
onChange={(e) => setSearchId(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setActiveId(searchId);
|
||||
}}
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
border: "1px solid rgba(88,166,255,0.3)",
|
||||
color: "#c9d1d9",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
width: "200px"
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setActiveId(searchId)}
|
||||
style={{
|
||||
background: "#1f6feb",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
padding: "5px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void downloadReport("json")}
|
||||
disabled={!activeId}
|
||||
style={{
|
||||
background: "rgba(31, 111, 235, 0.18)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(88,166,255,0.3)",
|
||||
padding: "5px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: activeId ? "pointer" : "not-allowed",
|
||||
fontWeight: 500,
|
||||
opacity: activeId ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void downloadReport("markdown")}
|
||||
disabled={!activeId}
|
||||
style={{
|
||||
background: "rgba(31, 111, 235, 0.18)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(88,166,255,0.3)",
|
||||
padding: "5px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: activeId ? "pointer" : "not-allowed",
|
||||
fontWeight: 500,
|
||||
opacity: activeId ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeId ? (
|
||||
<ReactFlow nodes={nodes} edges={edges} fitView>
|
||||
<Background color="#30363d" gap={20} />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<div style={{ display: "flex", height: "100%", width: "100%", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: "14px" }}>
|
||||
Enter a Node ID to view its W3C PROV-O lineage.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const SAMPLE_FACTS = `inhibits(Metformin, mTOR)\ncauses(mTOR, Neurodegeneration)`;
|
||||
const SAMPLE_RULE = `IF inhibits(Metformin, mTOR) AND causes(mTOR, Neurodegeneration) THEN candidate(Metformin, Alzheimer's)`;
|
||||
|
||||
export function ReasoningWorkspace() {
|
||||
const queryClient = useQueryClient();
|
||||
const [facts, setFacts] = useState(SAMPLE_FACTS);
|
||||
const [rules, setRules] = useState(SAMPLE_RULE);
|
||||
const [applyToGraph, setApplyToGraph] = useState(true);
|
||||
const [result, setResult] = useState<{ inferred_facts?: string[]; rules_fired?: number; added_edges?: number; mutated?: boolean } | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleRun() {
|
||||
setIsRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await fetch("/api/reason", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
facts: facts.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
rules: rules.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
mode: "forward",
|
||||
apply_to_graph: applyToGraph,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `Reasoning failed with status ${response.status}`);
|
||||
}
|
||||
setResult(data);
|
||||
if (data.mutated) {
|
||||
queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
|
||||
}
|
||||
} catch (runError) {
|
||||
setError(runError instanceof Error ? runError.message : "Reasoning failed");
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.2fr 1fr", gap: 24, height: "100%", padding: 24, boxSizing: "border-box", background: "#0d1117" }}>
|
||||
<div style={panelStyle}>
|
||||
<h3 style={titleStyle}>Facts</h3>
|
||||
<p style={copyStyle}>Enter one fact per line using `predicate(subject, object)` form.</p>
|
||||
<textarea value={facts} onChange={(event) => setFacts(event.target.value)} style={textareaStyle} />
|
||||
|
||||
<h3 style={{ ...titleStyle, marginTop: 18 }}>Rules</h3>
|
||||
<p style={copyStyle}>Write rules in `IF ... AND ... THEN ...` format. If the advanced reasoner is unavailable, the explorer falls back to an internal rule matcher for this format.</p>
|
||||
<textarea value={rules} onChange={(event) => setRules(event.target.value)} style={{ ...textareaStyle, minHeight: 160 }} />
|
||||
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, color: "#c9d1d9", fontSize: 13, marginTop: 16 }}>
|
||||
<input type="checkbox" checked={applyToGraph} onChange={(event) => setApplyToGraph(event.target.checked)} />
|
||||
Write inferred binary facts back into the graph as inferred edges
|
||||
</label>
|
||||
|
||||
<button onClick={handleRun} disabled={isRunning} style={runButtonStyle}>
|
||||
{isRunning ? "Running..." : "Run Reasoning"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={panelStyle}>
|
||||
<h3 style={titleStyle}>Inference Results</h3>
|
||||
|
||||
{error ? <div style={{ color: "#ff7b72", marginBottom: 12 }}>{error}</div> : null}
|
||||
|
||||
{result ? (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 14 }}>
|
||||
<span style={pillStyle}>rules fired: {result.rules_fired ?? 0}</span>
|
||||
<span style={pillStyle}>edges added: {result.added_edges ?? 0}</span>
|
||||
<span style={pillStyle}>{result.mutated ? "graph updated" : "preview only"}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{(result.inferred_facts || []).length ? (
|
||||
result.inferred_facts?.map((fact) => (
|
||||
<div key={fact} style={factCardStyle}>{fact}</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", fontSize: 13 }}>No inferred facts were produced.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", fontSize: 13 }}>Run a rule set to inspect inferred statements here.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(13, 17, 23, 0.78), rgba(22, 27, 34, 0.64))",
|
||||
border: "1px solid rgba(88, 166, 255, 0.18)",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
color: "#fff",
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const copyStyle: React.CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: "8px 0 14px",
|
||||
};
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
minHeight: 120,
|
||||
resize: "vertical",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(88, 166, 255, 0.18)",
|
||||
background: "rgba(0, 0, 0, 0.25)",
|
||||
color: "#e6edf3",
|
||||
padding: 12,
|
||||
fontFamily: "Consolas, monospace",
|
||||
fontSize: 13,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const runButtonStyle: React.CSSProperties = {
|
||||
marginTop: 18,
|
||||
border: "1px solid rgba(88, 166, 255, 0.3)",
|
||||
background: "rgba(31, 111, 235, 0.2)",
|
||||
color: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "11px 14px",
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const pillStyle: React.CSSProperties = {
|
||||
color: "#79c0ff",
|
||||
border: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
borderRadius: 999,
|
||||
padding: "5px 10px",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const factCardStyle: React.CSSProperties = {
|
||||
color: "#e6edf3",
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
borderRadius: 10,
|
||||
padding: "10px 12px",
|
||||
fontFamily: "Consolas, monospace",
|
||||
fontSize: 13,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx
|
||||
*/
|
||||
import { useState, useRef } from "react";
|
||||
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
export function SparqlWorkspace() {
|
||||
const monaco = useMonaco();
|
||||
const editorRef = useRef<any>(null);
|
||||
const [query, setQuery] = useState("SELECT ?s ?p ?o\nWHERE {\n ?s ?p ?o\n}\nLIMIT 10");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
function handleEditorWillMount(monacoIns: any) {
|
||||
if (!monacoIns.languages.getLanguages().some((l: any) => l.id === "sparql")) {
|
||||
monacoIns.languages.register({ id: "sparql" });
|
||||
|
||||
monacoIns.languages.setMonarchTokensProvider("sparql", {
|
||||
keywords: ["SELECT", "WHERE", "LIMIT", "FILTER", "OPTIONAL", "PREFIX", "ORDER BY", "DESC", "ASC"],
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/[a-zA-Z_]\w*/, { cases: { "@keywords": "keyword", "@default": "identifier" } }],
|
||||
[/[?\$][a-zA-Z_]\w*/, "variable.name"],
|
||||
[/<[^>]+>/, "string.uri"],
|
||||
[/".*?"/, "string"],
|
||||
[/#.*/, "comment"],
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
monacoIns.editor.defineTheme("sparql-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "58a6ff", fontStyle: "bold" },
|
||||
{ token: "variable.name", foreground: "79c0ff" },
|
||||
{ token: "string.uri", foreground: "a5d6ff" },
|
||||
{ token: "string", foreground: "a5d6ff" },
|
||||
{ token: "comment", foreground: "8b949e" }
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#0d1117",
|
||||
"editor.lineHighlightBackground": "#161b22",
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorDidMount(editor: any) {
|
||||
editorRef.current = editor;
|
||||
}
|
||||
|
||||
async function handleRun() {
|
||||
setIsLoading(true);
|
||||
setResult(null);
|
||||
monaco?.editor.setModelMarkers(editorRef.current.getModel(), "sparql", []);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/sparql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
if (data.error_line && monaco && editorRef.current) {
|
||||
monaco.editor.setModelMarkers(editorRef.current.getModel(), "sparql", [
|
||||
{
|
||||
startLineNumber: data.error_line,
|
||||
startColumn: data.error_column || 1,
|
||||
endLineNumber: data.error_line,
|
||||
endColumn: 100,
|
||||
message: data.error,
|
||||
severity: monaco.MarkerSeverity.Error
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 24, boxSizing: "border-box", gap: 24 }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h2 style={{ color: "#ffffff", margin: 0, fontSize: 24 }}>SPARQL Query Engine</h2>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isLoading}
|
||||
style={{ background: "#238636", color: "#fff", border: "none", padding: "8px 24px", borderRadius: 6, fontWeight: 600, cursor: "pointer" }}
|
||||
>
|
||||
{isLoading ? "Running..." : "Run Query"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, overflow: "hidden", border: "1px solid rgba(88,166,255,0.2)" }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sparql"
|
||||
theme="sparql-dark"
|
||||
value={query}
|
||||
onChange={(v) => setQuery(v || "")}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{ minimap: { enabled: false }, fontSize: 14, fontFamily: "monospace" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel" style={{ height: "30%", borderRadius: 12, padding: 16, overflowY: "auto" }}>
|
||||
<h3 style={{ color: "#ffffff", margin: "0 0 16px 0", fontSize: 16 }}>Results</h3>
|
||||
{result?.error ? (
|
||||
<div style={{ color: "#ff7b72" }}>{result.error}</div>
|
||||
) : result?.rows ? (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", color: "#c9d1d9" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
{result.columns.map((c: string) => <th key={c} style={{ textAlign: "left", padding: 8 }}>{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((r: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
{result.columns.map((c: string) => <td key={c} style={{ padding: 8 }}>{r[c]}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e" }}>No results to display. Run a query first.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* ConceptTree.tsx — react-arborist SKOS hierarchy viewer.
|
||||
*
|
||||
* Styled for the Palantir/Glassmorphism dark theme rather than
|
||||
* the default light-mode colours.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Tree } from 'react-arborist';
|
||||
import type { NodeRendererProps } from 'react-arborist';
|
||||
import { ChevronRight, ChevronDown, Folder, FileText } from 'lucide-react';
|
||||
import type { ConceptNode } from './types';
|
||||
|
||||
interface ConceptTreeProps {
|
||||
data: ConceptNode[];
|
||||
onSelectConcept: (concept: ConceptNode) => void;
|
||||
}
|
||||
|
||||
export const ConceptTree: React.FC<ConceptTreeProps> = ({ data, onSelectConcept }) => {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Tree
|
||||
data={data}
|
||||
idAccessor="uri"
|
||||
width="100%"
|
||||
height={600}
|
||||
indent={24}
|
||||
rowHeight={36}
|
||||
childrenAccessor="children"
|
||||
>
|
||||
{(nodeProps: NodeRendererProps<ConceptNode>) => {
|
||||
const { node, style, dragHandle } = nodeProps;
|
||||
const isFolder = node.children && node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
onClick={() => {
|
||||
node.toggle();
|
||||
onSelectConcept(node.data);
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: node.isSelected
|
||||
? 'rgba(88,166,255,0.12)'
|
||||
: 'transparent',
|
||||
userSelect: 'none',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!node.isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'rgba(88,166,255,0.06)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!node.isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 20, display: 'flex', justifyContent: 'center' }}>
|
||||
{isFolder ? (
|
||||
node.isOpen
|
||||
? <ChevronDown size={14} color="#8b949e" />
|
||||
: <ChevronRight size={14} color="#8b949e" />
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<span style={{ marginRight: 8, display: 'flex', alignItems: 'center' }}>
|
||||
{isFolder
|
||||
? <Folder size={14} color="#d2a8ff" />
|
||||
: <FileText size={14} color="#484f58" />
|
||||
}
|
||||
</span>
|
||||
|
||||
<span style={{
|
||||
fontSize: 13, color: '#c9d1d9',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{node.data.pref_label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Tree>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* ImportDropzone.tsx
|
||||
*
|
||||
* Drag & drop upload zone for SKOS .ttl / .rdf files.
|
||||
* Styled for the Palantir dark theme.
|
||||
*/
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { UploadCloud, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { useImportVocabulary } from './queries';
|
||||
import type { ImportResponse } from './types';
|
||||
|
||||
export const ImportDropzone: React.FC = () => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportResponse | null>(null);
|
||||
const importMutation = useImportVocabulary();
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
const selectedFile = acceptedFiles[0];
|
||||
setFile(selectedFile);
|
||||
setImportResult(null);
|
||||
|
||||
importMutation.mutate(selectedFile, {
|
||||
onSuccess: (data) => {
|
||||
setImportResult(data);
|
||||
setTimeout(() => {
|
||||
setFile(null);
|
||||
setImportResult(null);
|
||||
}, 4000);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("Upload failed:", err);
|
||||
setFile(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [importMutation]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'text/turtle': ['.ttl'],
|
||||
'application/rdf+xml': ['.rdf', '.owl']
|
||||
},
|
||||
maxFiles: 1
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
border: `2px dashed ${isDragActive ? '#58a6ff' : 'rgba(88,166,255,0.25)'}`,
|
||||
backgroundColor: isDragActive ? 'rgba(88,166,255,0.06)' : 'rgba(0,0,0,0.2)',
|
||||
borderRadius: 8,
|
||||
padding: '16px 12px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{importMutation.isPending ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: '#8b949e' }}>
|
||||
<Loader2 className="animate-spin" size={20} style={{ marginBottom: 6 }} />
|
||||
<span style={{ fontSize: 12 }}>Uploading {file?.name}…</span>
|
||||
</div>
|
||||
) : importResult ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: '#3fb950' }}>
|
||||
<CheckCircle2 size={20} style={{ marginBottom: 6 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 500 }}>Import Successful!</span>
|
||||
<span style={{ fontSize: 11, marginTop: 2, color: '#56d364' }}>
|
||||
+{importResult.nodes_added} concepts · +{importResult.edges_added} links
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: '#8b949e' }}>
|
||||
<UploadCloud size={20} style={{ marginBottom: 6, color: isDragActive ? '#58a6ff' : '#484f58' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: '#c9d1d9' }}>
|
||||
{isDragActive ? "Drop here…" : "Import Vocabulary"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, marginTop: 2 }}>.ttl or .rdf</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{importMutation.isError && (
|
||||
<p style={{ color: '#f85149', fontSize: 11, marginTop: 6, textAlign: 'center' }}>
|
||||
Upload failed. Check console.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* src/workspaces/VocabularyWorkspace/PropertyPanel.tsx
|
||||
*
|
||||
* Detail panel for a selected SKOS concept — shows preferred label,
|
||||
* URI, alt labels, and description.
|
||||
*/
|
||||
import type { ConceptNode } from './types';
|
||||
|
||||
interface PropertyPanelProps {
|
||||
concept: ConceptNode | null;
|
||||
}
|
||||
|
||||
export function PropertyPanel({ concept }: PropertyPanelProps) {
|
||||
if (!concept) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', height: '100%', color: '#8b949e',
|
||||
textAlign: 'center', padding: 40,
|
||||
}}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||
strokeLinejoin="round" style={{ marginBottom: 16, opacity: 0.4 }}>
|
||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" />
|
||||
</svg>
|
||||
<p style={{ fontSize: 15, fontWeight: 500, marginBottom: 4 }}>No concept selected</p>
|
||||
<p style={{ fontSize: 13 }}>Click a concept in the tree to view its properties.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 28, overflowY: 'auto', height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div style={{ borderBottom: '1px solid rgba(88,166,255,0.2)', paddingBottom: 20, marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||
background: '#d2a8ff', boxShadow: '0 0 8px rgba(210,168,255,0.6)',
|
||||
}} />
|
||||
<span style={{ color: '#d2a8ff', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
SKOS Concept
|
||||
</span>
|
||||
</div>
|
||||
<h2 style={{ margin: 0, color: '#fff', fontSize: 22, fontWeight: 700, wordBreak: 'break-word' }}>
|
||||
{concept.pref_label}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* URI Badge */}
|
||||
<div style={{
|
||||
marginBottom: 20, padding: '8px 14px',
|
||||
background: 'rgba(88,166,255,0.08)',
|
||||
border: '1px solid rgba(88,166,255,0.2)',
|
||||
borderRadius: 6, fontSize: 13, color: '#79c0ff',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{concept.uri}
|
||||
</div>
|
||||
|
||||
{/* Alt Labels */}
|
||||
{concept.alt_labels && concept.alt_labels.length > 0 && (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h4 style={{
|
||||
color: '#8b949e', fontSize: 12, textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em', marginBottom: 10,
|
||||
}}>
|
||||
Alternative Labels
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{concept.alt_labels.map((lbl, i) => (
|
||||
<span key={i} style={{
|
||||
background: '#21262d', color: '#c9d1d9',
|
||||
padding: '4px 10px', borderRadius: 4, fontSize: 13,
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
{lbl}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Children summary */}
|
||||
<section style={{
|
||||
background: 'rgba(0,0,0,0.2)', padding: 16, borderRadius: 8,
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<h4 style={{
|
||||
color: '#8b949e', fontSize: 12, textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em', marginBottom: 10,
|
||||
}}>
|
||||
Narrower Concepts
|
||||
</h4>
|
||||
{concept.children && concept.children.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{concept.children.map((child) => (
|
||||
<span key={child.uri} style={{
|
||||
color: '#c9d1d9', fontSize: 13, padding: '6px 10px',
|
||||
background: 'rgba(88,166,255,0.06)', borderRadius: 4,
|
||||
border: '1px solid rgba(88,166,255,0.12)',
|
||||
}}>
|
||||
{child.pref_label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ margin: 0, color: '#484f58', fontStyle: 'italic', fontSize: 13 }}>
|
||||
Leaf concept — no narrower concepts.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* src/workspaces/VocabularyWorkspace/Sidebar.tsx
|
||||
*
|
||||
* Left sidebar for the Vocabulary workspace.
|
||||
* - Lists available SKOS ConceptSchemes
|
||||
* - Shows the concept hierarchy tree for the active scheme
|
||||
* - Includes the import dropzone
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useVocabularies, useConceptHierarchy } from './queries';
|
||||
import { ConceptTree } from './ConceptTree';
|
||||
import { ImportDropzone } from './ImportDropzone';
|
||||
import type { ConceptNode, VocabularyScheme } from './types';
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectConcept: (concept: ConceptNode) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ onSelectConcept }: SidebarProps) {
|
||||
const { data: schemes = [], isLoading: schemesLoading } = useVocabularies();
|
||||
const [activeScheme, setActiveScheme] = useState<string | undefined>();
|
||||
|
||||
// Auto-select first scheme
|
||||
const selectedSchemeUri = activeScheme ?? schemes[0]?.uri;
|
||||
|
||||
const {
|
||||
data: hierarchy = [],
|
||||
isLoading: treeLoading,
|
||||
} = useConceptHierarchy(selectedSchemeUri);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 340, display: 'flex', flexDirection: 'column',
|
||||
borderRight: '1px solid rgba(88,166,255,0.15)',
|
||||
backgroundColor: '#010409', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px 20px 16px',
|
||||
borderBottom: '1px solid rgba(88,166,255,0.15)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: 18, color: '#c9d1d9', margin: '0 0 4px 0', fontWeight: 600 }}>
|
||||
Ontology & Vocabulary
|
||||
</h2>
|
||||
<p style={{ color: '#8b949e', fontSize: 13, margin: 0 }}>
|
||||
{schemes.length
|
||||
? `${schemes.length} vocabulary scheme${schemes.length > 1 ? 's' : ''}`
|
||||
: 'No vocabularies loaded'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scheme selector */}
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(88,166,255,0.1)' }}>
|
||||
{schemesLoading ? (
|
||||
<div style={{ color: '#8b949e', fontSize: 13 }}>Loading schemes…</div>
|
||||
) : schemes.length === 0 ? (
|
||||
<div style={{ color: '#484f58', fontSize: 13, fontStyle: 'italic' }}>
|
||||
No schemes found. Import a .ttl or .rdf file below.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedSchemeUri || ''}
|
||||
onChange={(e) => setActiveScheme(e.target.value)}
|
||||
style={{
|
||||
width: '100%', appearance: 'none',
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
border: '1px solid rgba(88,166,255,0.2)',
|
||||
color: '#c9d1d9', padding: '8px 12px',
|
||||
borderRadius: 6, fontSize: 13, cursor: 'pointer',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{schemes.map((s: VocabularyScheme) => (
|
||||
<option key={s.uri} value={s.uri} style={{ background: '#0d1117' }}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Concept Tree */}
|
||||
<div style={{
|
||||
flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{treeLoading ? (
|
||||
<div style={{ padding: 20, color: '#8b949e', fontSize: 13, textAlign: 'center' }}>
|
||||
Loading hierarchy…
|
||||
</div>
|
||||
) : hierarchy.length === 0 ? (
|
||||
<div style={{ padding: 20, color: '#484f58', fontSize: 13, textAlign: 'center', fontStyle: 'italic' }}>
|
||||
{selectedSchemeUri
|
||||
? 'No concepts found in this scheme.'
|
||||
: 'Select a scheme to browse concepts.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ConceptTree data={hierarchy} onSelectConcept={onSelectConcept} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Import area */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid rgba(88,166,255,0.15)',
|
||||
}}>
|
||||
<ImportDropzone />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx
|
||||
*
|
||||
* Main Vocabulary workspace — composes the Sidebar (scheme list + tree + import)
|
||||
* and the PropertyPanel (concept details) into a responsive two-column layout.
|
||||
*
|
||||
* All data fetching is handled by TanStack Query hooks in `queries.ts`.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { PropertyPanel } from './PropertyPanel';
|
||||
import type { ConceptNode } from './types';
|
||||
|
||||
const THEME_CSS = `
|
||||
.vocab-workspace {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0d1117;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vocab-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vocab-main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vocab-detail-glass {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.15);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.04);
|
||||
border-radius: 14px;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export function VocabularyWorkspace() {
|
||||
const [selectedConcept, setSelectedConcept] = useState<ConceptNode | null>(null);
|
||||
|
||||
return (
|
||||
<div className="vocab-workspace">
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Left: Sidebar with scheme list + concept tree + import */}
|
||||
<Sidebar onSelectConcept={setSelectedConcept} />
|
||||
|
||||
{/* Right: Concept detail panel */}
|
||||
<div className="vocab-main">
|
||||
{/* Decorative background gradient */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||
background: 'radial-gradient(ellipse at top right, rgba(210,168,255,0.04), transparent 60%)',
|
||||
}} />
|
||||
|
||||
<div className="vocab-main-content" style={{ padding: 32 }}>
|
||||
<div className="vocab-detail-glass">
|
||||
<PropertyPanel concept={selectedConcept} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// queries.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { VocabularyScheme, ConceptNode, ImportResponse } from './types';
|
||||
|
||||
const fetchSchemes = async (): Promise<VocabularyScheme[]> => {
|
||||
const res = await fetch('/api/vocabulary/schemes');
|
||||
if (!res.ok) throw new Error('Failed to fetch vocabularies');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const fetchHierarchy = async (schemeUri: string): Promise<ConceptNode[]> => {
|
||||
const res = await fetch(`/api/vocabulary/hierarchy?scheme=${encodeURIComponent(schemeUri)}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch hierarchy');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Updated to return the ImportResponse
|
||||
const importVocabulary = async (file: File): Promise<ImportResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/vocabulary/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to import vocabulary');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const useVocabularies = () => {
|
||||
return useQuery({
|
||||
queryKey: ['vocabularies'],
|
||||
queryFn: fetchSchemes
|
||||
});
|
||||
};
|
||||
|
||||
export const useConceptHierarchy = (schemeUri: string | undefined) => {
|
||||
return useQuery({
|
||||
queryKey: ['hierarchy', schemeUri],
|
||||
queryFn: () => fetchHierarchy(schemeUri!),
|
||||
enabled: !!schemeUri,
|
||||
});
|
||||
};
|
||||
|
||||
export const useImportVocabulary = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: importVocabulary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vocabularies'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface VocabularyScheme {
|
||||
uri: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ConceptNode {
|
||||
uri: string;
|
||||
pref_label: string;
|
||||
alt_labels: string[];
|
||||
description?: string;
|
||||
notation?: string;
|
||||
scheme_uri?: string;
|
||||
parent_uri?: string;
|
||||
children?: ConceptNode[] | null;
|
||||
}
|
||||
|
||||
export interface ImportResponse {
|
||||
status: string;
|
||||
filename?: string | null;
|
||||
nodes_added: number;
|
||||
edges_added: number;
|
||||
format?: string;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import Graph from "graphology";
|
||||
|
||||
import { curveGroupForPair, pairRegistryKey } from "../src/store/edgePairKeys.js";
|
||||
|
||||
function normalizeParallelMetadataForPair(graph, source, target) {
|
||||
const edgeIds = [];
|
||||
graph.forEachDirectedEdge(source, target, (edgeId) => {
|
||||
edgeIds.push(String(edgeId));
|
||||
});
|
||||
|
||||
const pairCount = edgeIds.length;
|
||||
const familyCounts = new Map();
|
||||
edgeIds.forEach((edgeId) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId);
|
||||
const familyId = String(attrs.familyId || edgeId);
|
||||
familyCounts.set(familyId, (familyCounts.get(familyId) ?? 0) + 1);
|
||||
});
|
||||
|
||||
edgeIds
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.forEach((edgeId, index) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId);
|
||||
const familyId = String(attrs.familyId || edgeId);
|
||||
graph.mergeEdgeAttributes(edgeId, {
|
||||
edgeId,
|
||||
familyId,
|
||||
sourceId: source,
|
||||
targetId: target,
|
||||
isParallelPair: pairCount > 1,
|
||||
parallelIndex: index,
|
||||
parallelCount: pairCount,
|
||||
familySize: familyCounts.get(familyId) ?? 1,
|
||||
curveGroup: curveGroupForPair(source, target),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function batchMergeEdges(graph, edges) {
|
||||
const touchedPairs = new Map();
|
||||
|
||||
for (const { id, familyId, source, target, attributes } of edges) {
|
||||
const edgeId = String(attributes.edgeId || id);
|
||||
const resolvedFamilyId = String(attributes.familyId || familyId || edgeId);
|
||||
|
||||
if (graph.hasNode(source) && graph.hasNode(target)) {
|
||||
graph.mergeDirectedEdgeWithKey(edgeId, source, target, {
|
||||
...attributes,
|
||||
edgeId,
|
||||
familyId: resolvedFamilyId,
|
||||
sourceId: source,
|
||||
targetId: target,
|
||||
});
|
||||
touchedPairs.set(pairRegistryKey(source, target), { source, target });
|
||||
}
|
||||
}
|
||||
|
||||
touchedPairs.forEach(({ source, target }) => {
|
||||
normalizeParallelMetadataForPair(graph, source, target);
|
||||
});
|
||||
}
|
||||
|
||||
test("structured pair keys support node ids containing double colons", () => {
|
||||
const graph = new Graph({ type: "directed", multi: true, allowSelfLoops: false });
|
||||
|
||||
graph.addNode("gene/protein::10");
|
||||
graph.addNode("gene/protein::472");
|
||||
graph.addNode("gene/protein::500");
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
batchMergeEdges(graph, [
|
||||
{
|
||||
id: "edge-a",
|
||||
familyId: "family-1",
|
||||
source: "gene/protein::10",
|
||||
target: "gene/protein::472",
|
||||
attributes: { edgeType: "protein_protein", weight: 1, properties: {} },
|
||||
},
|
||||
{
|
||||
id: "edge-b",
|
||||
familyId: "family-2",
|
||||
source: "gene/protein::10",
|
||||
target: "gene/protein::472",
|
||||
attributes: { edgeType: "protein_protein", weight: 1, properties: {} },
|
||||
},
|
||||
{
|
||||
id: "edge-c",
|
||||
familyId: "family-3",
|
||||
source: "gene/protein::10",
|
||||
target: "gene/protein::500",
|
||||
attributes: { edgeType: "protein_protein", weight: 1, properties: {} },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const edgeA = graph.getEdgeAttributes("edge-a");
|
||||
const edgeB = graph.getEdgeAttributes("edge-b");
|
||||
const edgeC = graph.getEdgeAttributes("edge-c");
|
||||
|
||||
assert.equal(edgeA.sourceId, "gene/protein::10");
|
||||
assert.equal(edgeA.targetId, "gene/protein::472");
|
||||
assert.equal(edgeA.parallelCount, 2);
|
||||
assert.equal(edgeB.parallelCount, 2);
|
||||
assert.equal(edgeA.isParallelPair, true);
|
||||
assert.equal(edgeB.isParallelPair, true);
|
||||
assert.equal(edgeC.parallelCount, 1);
|
||||
assert.equal(edgeC.isParallelPair, false);
|
||||
assert.equal(edgeA.curveGroup, JSON.stringify(["gene/protein::10", "gene/protein::472"]));
|
||||
assert.equal(edgeC.curveGroup, JSON.stringify(["gene/protein::10", "gene/protein::500"]));
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
],
|
||||
|
||||
base: '/',
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, '../semantica/static'),
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 650,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const normalizedId = id.replaceAll('\\', '/')
|
||||
|
||||
if (!normalizedId.includes('node_modules')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedId.includes('/node_modules/sigma/') ||
|
||||
normalizedId.includes('/node_modules/graphology/') ||
|
||||
normalizedId.includes('/node_modules/graphology-layout-forceatlas2/')
|
||||
) {
|
||||
return 'graph-vendor'
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedId.includes('/node_modules/vis-data/') ||
|
||||
normalizedId.includes('/node_modules/vis-timeline/')
|
||||
) {
|
||||
return 'timeline-vendor'
|
||||
}
|
||||
|
||||
if (normalizedId.includes('/node_modules/@tanstack/react-query/')) {
|
||||
return 'query-vendor'
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -82,6 +82,35 @@ class MemoryItem:
|
||||
embedding: Optional[Any] = None
|
||||
memory_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialise to a JSON-safe dict. Embeddings are dropped (not JSON-safe)."""
|
||||
return {
|
||||
"content": self.content,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"metadata": self.metadata,
|
||||
"entities": self.entities,
|
||||
"relationships": self.relationships,
|
||||
"memory_id": self.memory_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "MemoryItem":
|
||||
"""Reconstruct a MemoryItem from a serialised dict."""
|
||||
raw_ts = data.get("timestamp")
|
||||
try:
|
||||
ts = datetime.fromisoformat(raw_ts) if raw_ts else datetime.utcnow()
|
||||
except (ValueError, TypeError):
|
||||
ts = datetime.utcnow()
|
||||
return cls(
|
||||
content=data.get("content", ""),
|
||||
timestamp=ts,
|
||||
metadata=data.get("metadata", {}),
|
||||
entities=data.get("entities", []),
|
||||
relationships=data.get("relationships", []),
|
||||
embedding=None, # embeddings are not persisted; regenerate on demand
|
||||
memory_id=data.get("memory_id"),
|
||||
)
|
||||
|
||||
|
||||
class AgentMemory:
|
||||
"""
|
||||
@@ -141,20 +170,20 @@ class AgentMemory:
|
||||
Args:
|
||||
path: Directory path to save to
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
data = {
|
||||
"memory_items": self.memory_items,
|
||||
"memory_index": self.memory_index,
|
||||
"short_term_memory": self.short_term_memory,
|
||||
"memory_items": {k: v.to_dict() for k, v in self.memory_items.items()},
|
||||
"memory_index": list(self.memory_index),
|
||||
"short_term_memory": [item.to_dict() for item in self.short_term_memory],
|
||||
"stats": self.stats,
|
||||
}
|
||||
|
||||
with open(os.path.join(path, "agent_memory.pkl"), "wb") as f:
|
||||
pickle.dump(data, f)
|
||||
with open(os.path.join(path, "agent_memory.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
self.logger.info(f"Saved agent memory to {path}")
|
||||
|
||||
@@ -165,20 +194,38 @@ class AgentMemory:
|
||||
Args:
|
||||
path: Directory path to load from
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
|
||||
file_path = os.path.join(path, "agent_memory.pkl")
|
||||
if not os.path.exists(file_path):
|
||||
self.logger.warning(f"Memory file not found: {file_path}")
|
||||
# Support new JSON format; fall back to legacy filename only if it exists
|
||||
json_path = os.path.join(path, "agent_memory.json")
|
||||
legacy_path = os.path.join(path, "agent_memory.pkl")
|
||||
|
||||
if os.path.exists(json_path):
|
||||
file_path = json_path
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
elif os.path.exists(legacy_path):
|
||||
# Legacy pickle files: refuse to load them to prevent deserialization attacks.
|
||||
# Users must re-save memory in the new JSON format.
|
||||
self.logger.warning(
|
||||
f"Legacy pickle file found at {legacy_path}. "
|
||||
"Pickle loading is disabled for security. Re-save memory to migrate."
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.logger.warning(f"Memory file not found in: {path}")
|
||||
return
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
self.memory_items = data.get("memory_items", {})
|
||||
self.memory_index = data.get("memory_index", deque(maxlen=self.max_memory_size))
|
||||
self.short_term_memory = data.get("short_term_memory", [])
|
||||
raw_items = data.get("memory_items", {})
|
||||
self.memory_items = {
|
||||
k: MemoryItem.from_dict(v) for k, v in raw_items.items()
|
||||
}
|
||||
raw_index = data.get("memory_index", [])
|
||||
self.memory_index = deque(raw_index, maxlen=self.max_memory_size)
|
||||
self.short_term_memory = [
|
||||
MemoryItem.from_dict(item) for item in data.get("short_term_memory", [])
|
||||
]
|
||||
self.stats = data.get(
|
||||
"stats",
|
||||
{"total_items": 0, "items_by_type": {}, "last_accessed": None},
|
||||
|
||||
@@ -108,6 +108,7 @@ Production Use Cases:
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import threading
|
||||
import itertools
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
@@ -132,15 +133,31 @@ except ImportError:
|
||||
def _parse_iso_dt(value: str) -> Optional[datetime]:
|
||||
"""Parse an ISO datetime string into a tz-naive UTC datetime.
|
||||
|
||||
Always returns a naive datetime in UTC so callers can compare uniformly
|
||||
without worrying about mixed aware/naive arithmetic.
|
||||
Supported formats (in priority order):
|
||||
- Year-only shorthand: "1990" → "1990-01-01"
|
||||
- Date-only: "1990-06-15"
|
||||
- Full ISO (with tz): "1990-06-15T00:00:00+00:00" / "...Z"
|
||||
- Full ISO (naive): "1990-06-15T00:00:00"
|
||||
|
||||
Returns None on failure; callers must treat the node as Always-Active.
|
||||
"""
|
||||
import logging
|
||||
import re as _re
|
||||
if not value:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if _re.fullmatch(r"\d{4}", s):
|
||||
s = f"{s}-01-01"
|
||||
s = s.replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
dt = datetime.fromisoformat(s)
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
except (ValueError, AttributeError):
|
||||
except (ValueError, AttributeError) as e:
|
||||
logging.getLogger("semantica.context").warning(
|
||||
"Malformed temporal value %r — treating node as Always-Active. (%s)", value, e
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -162,6 +179,116 @@ def _normalize_temporal_input(value: Optional[Union[str, int, float, datetime]])
|
||||
raise ValueError("Temporal values must be datetime, epoch seconds, ISO strings, or None")
|
||||
|
||||
|
||||
def _pick_first(*values: Any) -> Any:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _default_edge_id(
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
edge_type: str,
|
||||
weight: float,
|
||||
metadata: Dict[str, Any],
|
||||
valid_from: Optional[str],
|
||||
valid_until: Optional[str],
|
||||
) -> str:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"source": source_id,
|
||||
"target": target_id,
|
||||
"type": edge_type,
|
||||
"weight": weight,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
"metadata": metadata,
|
||||
},
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, payload))
|
||||
|
||||
|
||||
def _resolve_edge_identity(
|
||||
*,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
edge_type: str,
|
||||
weight: float,
|
||||
metadata: Dict[str, Any],
|
||||
valid_from: Optional[str],
|
||||
valid_until: Optional[str],
|
||||
edge_id: Any = None,
|
||||
family_id: Any = None,
|
||||
) -> Tuple[str, str]:
|
||||
resolved_edge_id = str(
|
||||
_pick_first(
|
||||
edge_id,
|
||||
_default_edge_id(
|
||||
source_id=source_id,
|
||||
target_id=target_id,
|
||||
edge_type=edge_type,
|
||||
weight=weight,
|
||||
metadata=metadata,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
),
|
||||
)
|
||||
)
|
||||
resolved_family_id = str(_pick_first(family_id, resolved_edge_id))
|
||||
return resolved_edge_id, resolved_family_id
|
||||
|
||||
|
||||
def _coerce_metadata_map(*values: Any) -> Dict[str, Any]:
|
||||
merged: Dict[str, Any] = {}
|
||||
for value in values:
|
||||
if isinstance(value, dict):
|
||||
merged.update(value)
|
||||
return merged
|
||||
|
||||
|
||||
def _coerce_node_id(raw_node: Dict[str, Any]) -> Optional[str]:
|
||||
value = _pick_first(
|
||||
raw_node.get("id"),
|
||||
raw_node.get("node_id"),
|
||||
raw_node.get("_id"),
|
||||
raw_node.get("uri"),
|
||||
raw_node.get("key"),
|
||||
)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _coerce_edge_endpoint(raw_edge: Dict[str, Any], prefix: str) -> Optional[str]:
|
||||
prefix = prefix.lower()
|
||||
candidates = {
|
||||
"source": ["source_id", "source", "start", "start_id", "from", "src", "START_ID", ":START_ID"],
|
||||
"target": ["target_id", "target", "end", "end_id", "to", "dst", "END_ID", ":END_ID"],
|
||||
}[prefix]
|
||||
value = _pick_first(*(raw_edge.get(candidate) for candidate in candidates))
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _coerce_float(value: Any, default: float = 1.0) -> float:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextNode:
|
||||
"""Context graph node (Internal implementation)."""
|
||||
@@ -171,8 +298,8 @@ class ContextNode:
|
||||
content: str
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
properties: Dict[str, Any] = field(default_factory=dict)
|
||||
valid_from: Optional[str] = None # ISO datetime string, e.g. "2026-01-01T00:00:00"
|
||||
valid_until: Optional[str] = None # ISO datetime string; None = no expiry
|
||||
valid_from: Optional[str] = None
|
||||
valid_until: Optional[str] = None
|
||||
|
||||
def is_active(self, at_time: Optional[datetime] = None) -> bool:
|
||||
"""Return True if this node is active at the given time (defaults to now).
|
||||
@@ -212,11 +339,31 @@ class ContextEdge:
|
||||
source_id: str
|
||||
target_id: str
|
||||
edge_type: str
|
||||
edge_id: str = ""
|
||||
weight: float = 1.0
|
||||
family_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
valid_from: Optional[str] = None # ISO datetime string
|
||||
valid_until: Optional[str] = None # ISO datetime string; None = no expiry
|
||||
valid_from: Optional[str] = None
|
||||
valid_until: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.source_id = str(self.source_id)
|
||||
self.target_id = str(self.target_id)
|
||||
self.edge_type = str(self.edge_type or "related_to")
|
||||
self.weight = _coerce_float(self.weight, default=1.0)
|
||||
if not isinstance(self.metadata, dict):
|
||||
self.metadata = {}
|
||||
self.edge_id, self.family_id = _resolve_edge_identity(
|
||||
source_id=self.source_id,
|
||||
target_id=self.target_id,
|
||||
edge_type=self.edge_type,
|
||||
weight=self.weight,
|
||||
metadata=self.metadata,
|
||||
valid_from=self.valid_from,
|
||||
valid_until=self.valid_until,
|
||||
edge_id=self.edge_id,
|
||||
family_id=self.family_id,
|
||||
)
|
||||
def is_active(self, at_time: Optional[datetime] = None) -> bool:
|
||||
"""Return True if this edge is active at the given time (defaults to now).
|
||||
|
||||
@@ -239,6 +386,8 @@ class ContextEdge:
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary format."""
|
||||
d = {
|
||||
"id": self.edge_id,
|
||||
"familyId": self.family_id or self.edge_id,
|
||||
"source_id": self.source_id,
|
||||
"target_id": self.target_id,
|
||||
"type": self.edge_type,
|
||||
@@ -290,35 +439,31 @@ class ContextGraph:
|
||||
|
||||
self.entity_linker = self.config.get("entity_linker") or EntityLinker()
|
||||
|
||||
# Thread safety lock
|
||||
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Stable identifier so this graph can be referenced after save/load
|
||||
self.graph_id: str = str(uuid.uuid4())
|
||||
|
||||
# Graph structure
|
||||
self.nodes: Dict[str, ContextNode] = {}
|
||||
self.edges: List[ContextEdge] = []
|
||||
|
||||
# Adjacency list for efficient traversal: source_id -> list of edges
|
||||
self._adjacency: Dict[str, List[ContextEdge]] = defaultdict(list)
|
||||
|
||||
# Indexes
|
||||
|
||||
self.node_type_index: Dict[str, Set[str]] = defaultdict(set)
|
||||
self.edge_type_index: Dict[str, List[ContextEdge]] = defaultdict(list)
|
||||
|
||||
# Cross-graph navigation: link_id -> (other_graph, source_node_id, target_node_id)
|
||||
self._linked_graphs: Dict[str, Tuple["ContextGraph", str, str]] = {}
|
||||
# Unresolved link metadata (populated after load_from_file, before resolve_links)
|
||||
|
||||
self._unresolved_links: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
# Progress tracker
|
||||
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
# Ensure progress tracker is enabled
|
||||
|
||||
if not self.progress_tracker.enabled:
|
||||
self.progress_tracker.enabled = True
|
||||
|
||||
# Initialize advanced KG components if available
|
||||
|
||||
self.kg_components = {}
|
||||
self._analytics_cache = {}
|
||||
|
||||
@@ -344,7 +489,7 @@ class ContextGraph:
|
||||
self.logger.warning(f"Failed to initialize KG components: {e}")
|
||||
self.kg_components = {}
|
||||
|
||||
# --- GraphStore Protocol Implementation ---
|
||||
|
||||
|
||||
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
|
||||
"""
|
||||
@@ -358,29 +503,61 @@ class ContextGraph:
|
||||
"""
|
||||
count = 0
|
||||
with self._lock:
|
||||
for node in nodes:
|
||||
# Extract content from properties if not explicit
|
||||
node_props = node.get("properties", {})
|
||||
content = node_props.get("content", node.get("id"))
|
||||
# Restore validity windows from properties (written there by ContextNode.to_dict)
|
||||
# or from top-level keys on the node dict
|
||||
valid_from = (
|
||||
node.get("valid_from")
|
||||
or node_props.get("valid_from")
|
||||
for raw_node in nodes:
|
||||
if not isinstance(raw_node, dict):
|
||||
continue
|
||||
|
||||
node_id = _coerce_node_id(raw_node)
|
||||
if node_id is None:
|
||||
self.logger.warning("Skipping node without a usable id: %r", raw_node)
|
||||
continue
|
||||
|
||||
node_props = _coerce_metadata_map(
|
||||
raw_node.get("metadata"),
|
||||
raw_node.get("properties"),
|
||||
)
|
||||
valid_until = (
|
||||
node.get("valid_until")
|
||||
or node_props.get("valid_until")
|
||||
node_type = _pick_first(
|
||||
raw_node.get("type"),
|
||||
raw_node.get("node_type"),
|
||||
raw_node.get("category"),
|
||||
raw_node.get(":LABEL"),
|
||||
node_props.get("type"),
|
||||
"entity",
|
||||
)
|
||||
content = _pick_first(
|
||||
raw_node.get("content"),
|
||||
raw_node.get("text"),
|
||||
raw_node.get("label"),
|
||||
raw_node.get("name"),
|
||||
raw_node.get("title"),
|
||||
raw_node.get("pref_label"),
|
||||
node_props.get("content"),
|
||||
node_props.get("text"),
|
||||
node_props.get("label"),
|
||||
node_props.get("name"),
|
||||
node_props.get("title"),
|
||||
node_props.get("pref_label"),
|
||||
node_id,
|
||||
)
|
||||
|
||||
valid_from = _pick_first(
|
||||
raw_node.get("valid_from"),
|
||||
node_props.get("valid_from"),
|
||||
)
|
||||
valid_until = _pick_first(
|
||||
raw_node.get("valid_until"),
|
||||
node_props.get("valid_until"),
|
||||
)
|
||||
metadata = {
|
||||
k: v for k, v in node_props.items()
|
||||
if k not in ("content", "valid_from", "valid_until")
|
||||
k: v
|
||||
for k, v in node_props.items()
|
||||
if k not in ("content", "text", "valid_from", "valid_until")
|
||||
}
|
||||
|
||||
internal_node = ContextNode(
|
||||
node_id=node.get("id"),
|
||||
node_type=node.get("type", "entity"),
|
||||
content=content,
|
||||
node_id=node_id,
|
||||
node_type=str(node_type or "entity"),
|
||||
content=str(content or node_id),
|
||||
metadata=metadata,
|
||||
properties=node_props,
|
||||
valid_from=valid_from,
|
||||
@@ -404,22 +581,64 @@ class ContextGraph:
|
||||
"""
|
||||
count = 0
|
||||
with self._lock:
|
||||
for edge in edges:
|
||||
edge_props = edge.get("properties") or edge.get("metadata", {})
|
||||
valid_from = edge.get("valid_from") or edge_props.get("valid_from")
|
||||
valid_until = edge.get("valid_until") or edge_props.get("valid_until")
|
||||
|
||||
source_id = edge.get("source_id") or edge.get("source")
|
||||
target_id = edge.get("target_id") or edge.get("target")
|
||||
|
||||
if not source_id or not target_id:
|
||||
for raw_edge in edges:
|
||||
if not isinstance(raw_edge, dict):
|
||||
continue
|
||||
|
||||
internal_edge = ContextEdge(
|
||||
source_id = _coerce_edge_endpoint(raw_edge, "source")
|
||||
target_id = _coerce_edge_endpoint(raw_edge, "target")
|
||||
if source_id is None or target_id is None:
|
||||
self.logger.warning("Skipping edge without usable endpoints: %r", raw_edge)
|
||||
continue
|
||||
|
||||
edge_props = _coerce_metadata_map(
|
||||
raw_edge.get("metadata"),
|
||||
raw_edge.get("properties"),
|
||||
)
|
||||
edge_type = _pick_first(
|
||||
raw_edge.get("type"),
|
||||
raw_edge.get("edge_type"),
|
||||
raw_edge.get("relationship"),
|
||||
raw_edge.get("predicate"),
|
||||
raw_edge.get("relation"),
|
||||
raw_edge.get(":TYPE"),
|
||||
edge_props.get("type"),
|
||||
"related_to",
|
||||
)
|
||||
|
||||
valid_from = _pick_first(raw_edge.get("valid_from"), edge_props.get("valid_from"))
|
||||
valid_until = _pick_first(raw_edge.get("valid_until"), edge_props.get("valid_until"))
|
||||
weight = _coerce_float(_pick_first(raw_edge.get("weight"), edge_props.get("weight")), default=1.0)
|
||||
explicit_edge_id = _pick_first(
|
||||
raw_edge.get("id"),
|
||||
raw_edge.get("edge_id"),
|
||||
edge_props.pop("id", None),
|
||||
edge_props.pop("edge_id", None),
|
||||
)
|
||||
explicit_family_id = _pick_first(
|
||||
raw_edge.get("familyId"),
|
||||
raw_edge.get("family_id"),
|
||||
edge_props.pop("familyId", None),
|
||||
edge_props.pop("family_id", None),
|
||||
)
|
||||
edge_id, family_id = _resolve_edge_identity(
|
||||
source_id=source_id,
|
||||
target_id=target_id,
|
||||
edge_type=edge.get("type", "related_to"),
|
||||
weight=edge.get("weight", 1.0),
|
||||
edge_type=str(edge_type or "related_to"),
|
||||
weight=weight,
|
||||
metadata=edge_props,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
edge_id=explicit_edge_id,
|
||||
family_id=explicit_family_id,
|
||||
)
|
||||
internal_edge = ContextEdge(
|
||||
edge_id=edge_id,
|
||||
source_id=source_id,
|
||||
target_id=target_id,
|
||||
edge_type=str(edge_type or "related_to"),
|
||||
family_id=str(family_id) if family_id is not None else edge_id,
|
||||
weight=weight,
|
||||
metadata=edge_props,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
@@ -505,6 +724,8 @@ class ContextGraph:
|
||||
for edge in self._adjacency.get(source_id, []):
|
||||
if edge.target_id == target_id:
|
||||
data = edge.metadata.copy()
|
||||
data["id"] = edge.edge_id
|
||||
data["familyId"] = edge.family_id or edge.edge_id
|
||||
data["type"] = edge.edge_type
|
||||
data["weight"] = edge.weight
|
||||
return data
|
||||
@@ -671,12 +892,27 @@ class ContextGraph:
|
||||
"""
|
||||
valid_from = properties.pop("valid_from", None)
|
||||
valid_until = properties.pop("valid_until", None)
|
||||
explicit_edge_id = properties.pop("id", properties.pop("edge_id", None))
|
||||
explicit_family_id = properties.pop("familyId", properties.pop("family_id", None))
|
||||
edge_id, family_id = _resolve_edge_identity(
|
||||
source_id=source_id,
|
||||
target_id=target_id,
|
||||
edge_type=edge_type,
|
||||
weight=weight,
|
||||
metadata=properties,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
edge_id=explicit_edge_id,
|
||||
family_id=explicit_family_id,
|
||||
)
|
||||
with self._lock:
|
||||
return self._add_internal_edge(
|
||||
ContextEdge(
|
||||
edge_id=edge_id,
|
||||
source_id=source_id,
|
||||
target_id=target_id,
|
||||
edge_type=edge_type,
|
||||
family_id=str(family_id) if family_id is not None else edge_id,
|
||||
weight=weight,
|
||||
metadata=properties,
|
||||
valid_from=valid_from,
|
||||
@@ -694,8 +930,7 @@ class ContextGraph:
|
||||
import json
|
||||
|
||||
with self._lock:
|
||||
# Serialise cross-graph link metadata (object references are not serialisable,
|
||||
# so we store other_graph_id; callers can reconnect with resolve_links()).
|
||||
|
||||
links_data = []
|
||||
for link_id, (other_graph, source_node_id, target_node_id) in self._linked_graphs.items():
|
||||
links_data.append(
|
||||
@@ -736,6 +971,17 @@ class ContextGraph:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
if data and isinstance(data[0], dict) and any(
|
||||
key in data[0]
|
||||
for key in ("source", "source_id", "target", "target_id", "START_ID", ":START_ID")
|
||||
):
|
||||
data = {"edges": data, "nodes": []}
|
||||
else:
|
||||
data = {"nodes": data, "edges": []}
|
||||
elif not isinstance(data, dict):
|
||||
raise ValueError("Graph file must contain a JSON object or array payload")
|
||||
|
||||
with self._lock:
|
||||
# Clear existing
|
||||
self.nodes.clear()
|
||||
@@ -750,10 +996,22 @@ class ContextGraph:
|
||||
self.graph_id = data["graph_id"]
|
||||
|
||||
|
||||
nodes = data.get("nodes", [])
|
||||
nodes = data.get("nodes")
|
||||
if nodes is None:
|
||||
nodes = data.get("entities")
|
||||
if nodes is None:
|
||||
nodes = data.get("vertices")
|
||||
if nodes is None:
|
||||
nodes = []
|
||||
self.add_nodes(nodes)
|
||||
|
||||
edges = data.get("edges", [])
|
||||
edges = data.get("edges")
|
||||
if edges is None:
|
||||
edges = data.get("relationships")
|
||||
if edges is None:
|
||||
edges = data.get("links")
|
||||
if edges is None:
|
||||
edges = []
|
||||
self.add_edges(edges)
|
||||
|
||||
|
||||
@@ -786,11 +1044,10 @@ class ContextGraph:
|
||||
"""Find nodes lazily"""
|
||||
with self._lock:
|
||||
if node_type:
|
||||
# Sets are unordered, sort IDs for deterministic pagination.
|
||||
# Guard against non-string IDs (None/int) which cause sorted() TypeError.
|
||||
# Sets are unordered, sort IDs for deterministic pagination
|
||||
raw_ids = sorted(
|
||||
nid for nid in self.node_type_index.get(node_type, set())
|
||||
if isinstance(nid, str)
|
||||
(node_id for node_id in self.node_type_index.get(node_type, set()) if node_id is not None),
|
||||
key=lambda value: str(value),
|
||||
)
|
||||
source = (self.nodes[nid] for nid in raw_ids if nid in self.nodes)
|
||||
else:
|
||||
@@ -821,8 +1078,8 @@ class ContextGraph:
|
||||
with self._lock:
|
||||
if node_type:
|
||||
raw_ids = sorted(
|
||||
nid for nid in self.node_type_index.get(node_type, set())
|
||||
if isinstance(nid, str)
|
||||
(node_id for node_id in self.node_type_index.get(node_type, set()) if node_id is not None),
|
||||
key=lambda value: str(value),
|
||||
)
|
||||
source = (self.nodes[nid] for nid in raw_ids if nid in self.nodes)
|
||||
else:
|
||||
@@ -983,11 +1240,15 @@ class ContextGraph:
|
||||
|
||||
gen = (
|
||||
{
|
||||
"source": e.source_id or "",
|
||||
"target": e.target_id or "",
|
||||
"type": e.edge_type or "related_to",
|
||||
"weight": e.weight if e.weight is not None else 1.0,
|
||||
"metadata": e.metadata or {},
|
||||
"id": e.edge_id,
|
||||
"familyId": e.family_id or e.edge_id,
|
||||
"source": e.source_id,
|
||||
"target": e.target_id,
|
||||
"type": e.edge_type,
|
||||
"weight": e.weight,
|
||||
"metadata": e.metadata,
|
||||
"valid_from": e.valid_from,
|
||||
"valid_until": e.valid_until,
|
||||
}
|
||||
for e in source if e.source_id and e.target_id
|
||||
)
|
||||
@@ -1067,7 +1328,7 @@ class ContextGraph:
|
||||
return datetime.fromtimestamp(timestamp_value)
|
||||
elif isinstance(timestamp_value, str):
|
||||
# Handle ISO format with optional Z suffix
|
||||
timestamp_str = timestamp_value.rstrip('Z') # Remove Z if present
|
||||
timestamp_str = timestamp_value.rstrip('Z') #
|
||||
try:
|
||||
return datetime.fromisoformat(timestamp_str)
|
||||
except ValueError:
|
||||
@@ -1079,6 +1340,9 @@ class ContextGraph:
|
||||
|
||||
def _add_internal_node(self, node: ContextNode) -> bool:
|
||||
"""Internal method to add a node."""
|
||||
if node.node_id is None or (isinstance(node.node_id, str) and not node.node_id.strip()):
|
||||
self.logger.warning("Skipping internal node with invalid id: %r", node)
|
||||
return False
|
||||
with self._lock:
|
||||
self.nodes[node.node_id] = node
|
||||
# Handle edge case where node_type might be None or not a string
|
||||
@@ -1099,6 +1363,9 @@ class ContextGraph:
|
||||
|
||||
def _add_internal_edge(self, edge: ContextEdge) -> bool:
|
||||
"""Internal method to add an edge."""
|
||||
if edge.source_id is None or edge.target_id is None:
|
||||
self.logger.warning("Skipping internal edge with invalid endpoints: %r", edge)
|
||||
return False
|
||||
with self._lock:
|
||||
# Ensure nodes exist
|
||||
if edge.source_id not in self.nodes:
|
||||
@@ -1117,14 +1384,11 @@ class ContextGraph:
|
||||
if getattr(self, "mutation_callback", None) and not getattr(
|
||||
self, "_suspend_mutation_callback", False
|
||||
):
|
||||
import json
|
||||
|
||||
edge_id = json.dumps([edge.source_id, edge.edge_type, edge.target_id])
|
||||
try:
|
||||
self.mutation_callback("ADD_EDGE", edge_id, edge.to_dict())
|
||||
self.mutation_callback("ADD_EDGE", edge.edge_id, edge.to_dict())
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Audit trail callback failed for edge {edge_id}: {e}"
|
||||
f"Audit trail callback failed for edge {edge.edge_id}: {e}"
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -1382,11 +1646,15 @@ class ContextGraph:
|
||||
edges_out = []
|
||||
for e in self.edges:
|
||||
entry = {
|
||||
"id": e.edge_id,
|
||||
"familyId": e.family_id or e.edge_id,
|
||||
"source": e.source_id,
|
||||
"target": e.target_id,
|
||||
"type": e.edge_type,
|
||||
"weight": e.weight,
|
||||
}
|
||||
if e.metadata:
|
||||
entry["metadata"] = e.metadata
|
||||
if e.valid_from is not None:
|
||||
entry["valid_from"] = e.valid_from
|
||||
if e.valid_until is not None:
|
||||
@@ -1423,12 +1691,27 @@ class ContextGraph:
|
||||
|
||||
# Add edges — restore validity windows if present
|
||||
for edge_data in graph_dict.get("edges", []):
|
||||
edge = ContextEdge(
|
||||
edge_metadata = edge_data.get("metadata", edge_data.get("properties", {})) or {}
|
||||
edge_weight = edge_data.get("weight", 1.0)
|
||||
edge_id, family_id = _resolve_edge_identity(
|
||||
source_id=edge_data["source"],
|
||||
target_id=edge_data["target"],
|
||||
edge_type=edge_data["type"],
|
||||
weight=edge_data.get("weight", 1.0),
|
||||
metadata=edge_data.get("metadata", {}),
|
||||
weight=edge_weight,
|
||||
metadata=edge_metadata,
|
||||
valid_from=edge_data.get("valid_from"),
|
||||
valid_until=edge_data.get("valid_until"),
|
||||
edge_id=edge_data.get("id", edge_data.get("edge_id")),
|
||||
family_id=edge_data.get("familyId", edge_data.get("family_id")),
|
||||
)
|
||||
edge = ContextEdge(
|
||||
edge_id=edge_id,
|
||||
source_id=edge_data["source"],
|
||||
target_id=edge_data["target"],
|
||||
edge_type=edge_data["type"],
|
||||
weight=edge_weight,
|
||||
family_id=family_id,
|
||||
metadata=edge_metadata,
|
||||
valid_from=edge_data.get("valid_from"),
|
||||
valid_until=edge_data.get("valid_until"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Module entry point for ``python -m semantica.explorer``."""
|
||||
|
||||
from . import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+81
-46
@@ -1,39 +1,50 @@
|
||||
"""
|
||||
Semantica Explorer — FastAPI Application Factory
|
||||
|
||||
Creates and configures the FastAPI app with CORS, error handling,
|
||||
static file serving, route registration, and WebSocket support.
|
||||
"""
|
||||
Semantica Explorer FastAPI application factory.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .. import __version__
|
||||
from .session import GraphSession
|
||||
from .ws import ConnectionManager
|
||||
|
||||
|
||||
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
|
||||
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
|
||||
loop = getattr(app.state, "event_loop", None)
|
||||
manager = getattr(app.state, "ws_manager", None)
|
||||
if loop is None or manager is None or loop.is_closed():
|
||||
return
|
||||
message = {
|
||||
"event_type": event_type,
|
||||
"entity_id": entity_id,
|
||||
"payload": payload,
|
||||
}
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
manager.broadcast("graph_mutation", message),
|
||||
loop,
|
||||
)
|
||||
|
||||
session.graph.mutation_callback = on_mutation
|
||||
|
||||
|
||||
def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
"""
|
||||
Build a fully-configured FastAPI application.
|
||||
|
||||
Args:
|
||||
session: Pre-built ``GraphSession``. If ``None`` the caller must
|
||||
attach one to ``app.state.session`` before the first
|
||||
request arrives.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.event_loop = asyncio.get_running_loop()
|
||||
app.state.ws_manager = ConnectionManager()
|
||||
if session is not None:
|
||||
app.state.session = session
|
||||
app.state.ws_manager = ConnectionManager()
|
||||
_install_mutation_bridge(app, session)
|
||||
yield
|
||||
|
||||
app = FastAPI(
|
||||
@@ -43,48 +54,49 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
cors_origins = os.environ.get("EXPLORER_CORS_ORIGINS", "*")
|
||||
_raw_origins = os.environ.get(
|
||||
"EXPLORER_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"
|
||||
)
|
||||
_cors_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins.split(","),
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
max_age=600,
|
||||
)
|
||||
|
||||
import logging as _logging
|
||||
_logger = _logging.getLogger(__name__)
|
||||
|
||||
@app.exception_handler(KeyError)
|
||||
async def key_error_handler(request: Request, exc: KeyError):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": f"Not found: {exc}"},
|
||||
)
|
||||
async def key_error_handler(_request: Request, exc: KeyError):
|
||||
_logger.warning("KeyError: %s", exc)
|
||||
return JSONResponse(status_code=404, content={"detail": "Resource not found"})
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(request: Request, exc: ValueError):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
async def value_error_handler(_request: Request, exc: ValueError):
|
||||
_logger.warning("ValueError: %s", exc)
|
||||
return JSONResponse(status_code=422, content={"detail": "Invalid input"})
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def generic_error_handler(request: Request, exc: Exception):
|
||||
# Let FastAPI's built-in HTTPException handler take precedence so that
|
||||
# responses from dependency injection (e.g. 503 from get_session) are
|
||||
# not swallowed and converted to 500.
|
||||
async def generic_error_handler(_request: Request, exc: Exception):
|
||||
if isinstance(exc, HTTPException):
|
||||
raise exc
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal Server Error"},
|
||||
)
|
||||
_logger.exception("Unhandled exception")
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
from .routes.graph import router as graph_router
|
||||
from .routes.analytics import router as analytics_router
|
||||
from .routes.annotations import router as annotations_router
|
||||
from .routes.decisions import router as decisions_router
|
||||
from .routes.temporal import router as temporal_router
|
||||
from .routes.enrich import router as enrich_router
|
||||
from .routes.export_import import router as export_import_router
|
||||
from .routes.annotations import router as annotations_router
|
||||
from .routes.graph import router as graph_router
|
||||
from .routes.provenance import router as provenance_router
|
||||
from .routes.sparql import router as sparql_router
|
||||
from .routes.temporal import router as temporal_router
|
||||
from .routes.vocabulary import router as vocabulary_router
|
||||
|
||||
app.include_router(graph_router)
|
||||
app.include_router(analytics_router)
|
||||
@@ -93,16 +105,25 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
app.include_router(enrich_router)
|
||||
app.include_router(export_import_router)
|
||||
app.include_router(annotations_router)
|
||||
app.include_router(sparql_router)
|
||||
app.include_router(provenance_router)
|
||||
app.include_router(vocabulary_router)
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
_WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only
|
||||
|
||||
@app.websocket("/ws/graph-updates")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
manager: ConnectionManager = app.state.ws_manager
|
||||
await manager.connect(websocket)
|
||||
await manager.send_personal(websocket, "connection_ack", {"connected": True})
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
message = await websocket.receive_text()
|
||||
if len(message) > _WS_MAX_MESSAGE_BYTES:
|
||||
await websocket.close(code=1009) # 1009 = message too big
|
||||
break
|
||||
if message.strip().lower() == "ping":
|
||||
await manager.send_personal(websocket, "pong", {"ok": True})
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
@@ -120,7 +141,21 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
|
||||
static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
if static_dir.is_dir():
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
||||
assets_dir = static_dir / "assets"
|
||||
if assets_dir.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def serve_spa(full_path: str):
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="API route not found")
|
||||
index_path = static_dir / "index.html"
|
||||
if index_path.is_file():
|
||||
return FileResponse(index_path)
|
||||
raise HTTPException(status_code=404, detail="Frontend build missing")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Module-level app instance used by uvicorn and Docker CMD.
|
||||
app = create_app()
|
||||
|
||||
@@ -5,7 +5,6 @@ Provides ``Depends()``-compatible callables for injecting the
|
||||
current ``GraphSession`` and ``ConnectionManager`` into route handlers.
|
||||
"""
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi import Request, HTTPException, status
|
||||
|
||||
from .session import GraphSession
|
||||
@@ -24,4 +23,9 @@ def get_session(request: Request) -> GraphSession:
|
||||
|
||||
def get_ws_manager(request: Request) -> ConnectionManager:
|
||||
"""Retrieve the ConnectionManager stored on ``app.state``."""
|
||||
if not hasattr(request.app.state, "ws_manager") or request.app.state.ws_manager is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="WebSocket manager not initialized.",
|
||||
)
|
||||
return request.app.state.ws_manager
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Analytics routes : centrality, community, connectivity, validation.
|
||||
"""
|
||||
Analytics routes for graph metrics and validation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -14,24 +14,6 @@ from ..session import GraphSession
|
||||
router = APIRouter(prefix="/api/analytics", tags=["Analytics"])
|
||||
|
||||
|
||||
def _build_graph_dict(session: GraphSession) -> dict:
|
||||
"""Build the entity/relationship dict expected by KG analysers."""
|
||||
nodes, _ = session.get_nodes(skip=0, limit=999_999)
|
||||
edges, _ = session.get_edges(skip=0, limit=999_999)
|
||||
return {
|
||||
"entities": [
|
||||
{"id": n.get("id"), "type": n.get("type", "entity"),
|
||||
"text": n.get("content", n.get("id", "")), "metadata": n.get("metadata", {})}
|
||||
for n in nodes
|
||||
],
|
||||
"relationships": [
|
||||
{"source": e.get("source"), "target": e.get("target"),
|
||||
"type": e.get("type", "related_to"), "metadata": e.get("metadata", {})}
|
||||
for e in edges
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=AnalyticsResponse)
|
||||
async def get_analytics(
|
||||
metrics: Optional[str] = Query(
|
||||
@@ -40,36 +22,34 @@ async def get_analytics(
|
||||
),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Compute graph analytics (centrality, community, connectivity)."""
|
||||
requested = set((metrics or "centrality,community,connectivity").split(","))
|
||||
graph_dict = await asyncio.to_thread(_build_graph_dict, session)
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
result: dict = {}
|
||||
|
||||
if "centrality" in requested and session.centrality is not None:
|
||||
try:
|
||||
centrality = await asyncio.to_thread(
|
||||
session.centrality.calculate_degree_centrality, graph_dict
|
||||
result["centrality"] = await asyncio.to_thread(
|
||||
session.centrality.calculate_degree_centrality,
|
||||
graph_dict,
|
||||
)
|
||||
result["centrality"] = centrality
|
||||
except Exception as exc:
|
||||
result["centrality"] = {"error": str(exc)}
|
||||
|
||||
if "community" in requested and session.community is not None:
|
||||
try:
|
||||
community = await asyncio.to_thread(
|
||||
session.community.detect_communities, graph_dict
|
||||
result["community"] = await asyncio.to_thread(
|
||||
session.community.detect_communities,
|
||||
graph_dict,
|
||||
)
|
||||
result["community"] = community
|
||||
except Exception as exc:
|
||||
result["community"] = {"error": str(exc)}
|
||||
|
||||
if "connectivity" in requested and session.connectivity is not None:
|
||||
try:
|
||||
connectivity = await asyncio.to_thread(
|
||||
session.connectivity.analyze_connectivity, graph_dict
|
||||
result["connectivity"] = await asyncio.to_thread(
|
||||
session.connectivity.analyze_connectivity,
|
||||
graph_dict,
|
||||
)
|
||||
result["connectivity"] = connectivity
|
||||
except Exception as exc:
|
||||
result["connectivity"] = {"error": str(exc)}
|
||||
|
||||
@@ -80,14 +60,11 @@ async def get_analytics(
|
||||
async def validate_graph(
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Run graph validation and return a pass/fail report."""
|
||||
validator = session.validator
|
||||
if validator is None:
|
||||
return ValidationReportResponse(valid=True, error_count=0, warning_count=0, issues=[])
|
||||
|
||||
graph_dict = await asyncio.to_thread(_build_graph_dict, session)
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
|
||||
try:
|
||||
report = await asyncio.to_thread(validator.validate, graph_dict)
|
||||
except Exception as exc:
|
||||
@@ -97,7 +74,6 @@ async def validate_graph(
|
||||
issues=[ValidationIssue(severity="error", message=str(exc))],
|
||||
)
|
||||
|
||||
|
||||
if isinstance(report, dict):
|
||||
valid = report.get("valid", True)
|
||||
errors = report.get("errors", [])
|
||||
@@ -107,13 +83,8 @@ async def validate_graph(
|
||||
errors = getattr(report, "errors", [])
|
||||
warnings = getattr(report, "warnings", [])
|
||||
|
||||
issues = []
|
||||
for e in (errors or []):
|
||||
msg = e if isinstance(e, str) else str(e)
|
||||
issues.append(ValidationIssue(severity="error", message=msg))
|
||||
for w in (warnings or []):
|
||||
msg = w if isinstance(w, str) else str(w)
|
||||
issues.append(ValidationIssue(severity="warning", message=msg))
|
||||
issues = [ValidationIssue(severity="error", message=str(error)) for error in (errors or [])]
|
||||
issues.extend(ValidationIssue(severity="warning", message=str(warning)) for warning in (warnings or []))
|
||||
|
||||
return ValidationReportResponse(
|
||||
valid=valid,
|
||||
|
||||
@@ -40,11 +40,9 @@ async def create_annotation(
|
||||
ann_data = body.model_dump()
|
||||
ann_id = await asyncio.to_thread(session.add_annotation, ann_data)
|
||||
|
||||
|
||||
anns = await asyncio.to_thread(session.get_annotations)
|
||||
for a in anns:
|
||||
if a.get("annotation_id") == ann_id:
|
||||
return AnnotationResponse(**a)
|
||||
stored = await asyncio.to_thread(session.get_annotation, ann_id)
|
||||
if stored is not None:
|
||||
return AnnotationResponse(**stored)
|
||||
|
||||
return AnnotationResponse(
|
||||
annotation_id=ann_id,
|
||||
@@ -53,9 +51,6 @@ async def create_annotation(
|
||||
tags=body.tags,
|
||||
visibility=body.visibility,
|
||||
)
|
||||
# add_annotation mutates ann_data in-place, adding annotation_id and created_at.
|
||||
await asyncio.to_thread(session.add_annotation, ann_data)
|
||||
return AnnotationResponse(**ann_data)
|
||||
|
||||
|
||||
@router.delete("/{annotation_id}", status_code=204)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""
|
||||
Decision routes : decision listing, causal chains, precedents, compliance.
|
||||
|
||||
Uses ContextGraph-native queries so it works without a Neo4j/FalkorDB backend.
|
||||
"""
|
||||
Decision routes using ContextGraph-native fallbacks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -10,28 +8,23 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import (
|
||||
CausalChainResponse,
|
||||
ComplianceResponse,
|
||||
DecisionResponse,
|
||||
)
|
||||
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/decisions", tags=["Decisions"])
|
||||
|
||||
|
||||
def _node_to_decision(n: dict) -> DecisionResponse:
|
||||
"""Map a ContextGraph node dict to a DecisionResponse."""
|
||||
meta = n.get("metadata", {})
|
||||
def _node_to_decision(node: dict) -> DecisionResponse:
|
||||
properties = node.get("properties", {})
|
||||
return DecisionResponse(
|
||||
decision_id=n.get("id", ""),
|
||||
category=meta.get("category", ""),
|
||||
scenario=meta.get("scenario", ""),
|
||||
reasoning=meta.get("reasoning", ""),
|
||||
outcome=meta.get("outcome", ""),
|
||||
confidence=float(meta.get("confidence", 0.0)),
|
||||
timestamp=meta.get("timestamp"),
|
||||
metadata=meta,
|
||||
decision_id=node.get("id", ""),
|
||||
category=properties.get("category", ""),
|
||||
scenario=properties.get("scenario", ""),
|
||||
reasoning=properties.get("reasoning", ""),
|
||||
outcome=properties.get("outcome", ""),
|
||||
confidence=float(properties.get("confidence", 0.0) or 0.0),
|
||||
timestamp=properties.get("timestamp"),
|
||||
metadata=properties,
|
||||
)
|
||||
|
||||
|
||||
@@ -42,19 +35,21 @@ async def list_decisions(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""List decision nodes (type='decision') with optional category filter."""
|
||||
nodes, _ = await asyncio.to_thread(
|
||||
session.get_nodes, node_type="decision", skip=0, limit=999_999
|
||||
session.get_nodes,
|
||||
node_type="decision",
|
||||
skip=0,
|
||||
limit=999_999,
|
||||
)
|
||||
|
||||
if category:
|
||||
nodes = [
|
||||
n for n in nodes
|
||||
if n.get("metadata", {}).get("category", "").lower() == category.lower()
|
||||
node
|
||||
for node in nodes
|
||||
if str(node.get("properties", {}).get("category", "")).lower() == category.lower()
|
||||
]
|
||||
|
||||
page = nodes[skip: skip + limit]
|
||||
return [_node_to_decision(n) for n in page]
|
||||
return [_node_to_decision(node) for node in nodes[skip : skip + limit]]
|
||||
|
||||
|
||||
@router.get("/{decision_id}", response_model=DecisionResponse)
|
||||
@@ -62,7 +57,6 @@ async def get_decision(
|
||||
decision_id: str,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Get a single decision by ID."""
|
||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||
if node is None:
|
||||
raise KeyError(decision_id)
|
||||
@@ -74,27 +68,20 @@ async def get_causal_chain(
|
||||
decision_id: str,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Trace the causal chain for a decision.
|
||||
|
||||
Uses BFS neighbour traversal over ``caused_by`` / ``influences``
|
||||
relationship types as a lightweight, backend-agnostic fallback.
|
||||
"""
|
||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||
if node is None:
|
||||
raise KeyError(decision_id)
|
||||
|
||||
# Walk outbound causal edges (up to 5 hops)
|
||||
neighbors = await asyncio.to_thread(session.get_neighbors, decision_id, depth=5)
|
||||
neighbors = await asyncio.to_thread(session.get_neighbors, decision_id, 5)
|
||||
chain = [
|
||||
{
|
||||
"id": nb.get("id"),
|
||||
"type": nb.get("type"),
|
||||
"relationship": nb.get("relationship"),
|
||||
"hop": nb.get("hop"),
|
||||
"content": nb.get("content", ""),
|
||||
"id": neighbor.get("id"),
|
||||
"type": neighbor.get("type"),
|
||||
"relationship": neighbor.get("relationship"),
|
||||
"hop": neighbor.get("hop"),
|
||||
"content": neighbor.get("content", ""),
|
||||
}
|
||||
for nb in neighbors
|
||||
for neighbor in neighbors
|
||||
]
|
||||
return CausalChainResponse(decision_id=decision_id, chain=chain)
|
||||
|
||||
@@ -105,40 +92,37 @@ async def get_precedents(
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Find precedent decisions similar to the given decision.
|
||||
|
||||
Lightweight: looks for other decision-type nodes and ranks by shared
|
||||
category and keyword overlap.
|
||||
"""
|
||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||
if node is None:
|
||||
raise KeyError(decision_id)
|
||||
|
||||
meta = node.get("metadata", {})
|
||||
category = meta.get("category", "")
|
||||
scenario_words = set(meta.get("scenario", "").lower().split())
|
||||
properties = node.get("properties", {})
|
||||
category = str(properties.get("category", ""))
|
||||
scenario_words = set(str(properties.get("scenario", "")).lower().split())
|
||||
|
||||
all_decisions, _ = await asyncio.to_thread(
|
||||
session.get_nodes, node_type="decision", skip=0, limit=999_999
|
||||
session.get_nodes,
|
||||
node_type="decision",
|
||||
skip=0,
|
||||
limit=999_999,
|
||||
)
|
||||
|
||||
scored = []
|
||||
for d in all_decisions:
|
||||
if d.get("id") == decision_id:
|
||||
for decision in all_decisions:
|
||||
if decision.get("id") == decision_id:
|
||||
continue
|
||||
d_meta = d.get("metadata", {})
|
||||
other_props = decision.get("properties", {})
|
||||
score = 0.0
|
||||
if d_meta.get("category", "").lower() == category.lower() and category:
|
||||
if category and str(other_props.get("category", "")).lower() == category.lower():
|
||||
score += 0.5
|
||||
d_words = set(d_meta.get("scenario", "").lower().split())
|
||||
if scenario_words and d_words:
|
||||
overlap = len(scenario_words & d_words) / max(len(scenario_words | d_words), 1)
|
||||
other_words = set(str(other_props.get("scenario", "")).lower().split())
|
||||
if scenario_words and other_words:
|
||||
overlap = len(scenario_words & other_words) / max(len(scenario_words | other_words), 1)
|
||||
score += 0.5 * overlap
|
||||
scored.append((score, d))
|
||||
scored.append((score, decision))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [_node_to_decision(d) for _, d in scored[:limit]]
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
return [_node_to_decision(decision) for _, decision in scored[:limit]]
|
||||
|
||||
|
||||
@router.get("/{decision_id}/compliance", response_model=ComplianceResponse)
|
||||
@@ -146,35 +130,20 @@ async def check_compliance(
|
||||
decision_id: str,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Check policy compliance for a decision.
|
||||
|
||||
Returns a stub result when no PolicyEngine is wired up.
|
||||
Inspects edges of type ``violates``, ``non_compliant``, or ``breaches``
|
||||
originating from the decision node. Returns ``compliant=True`` when no
|
||||
such edges are found, which is the correct result for graphs that have
|
||||
no policy-violation edges defined.
|
||||
"""
|
||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||
if node is None:
|
||||
raise KeyError(decision_id)
|
||||
|
||||
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
|
||||
_VIOLATION_TYPES = {"violates", "non_compliant", "breaches"}
|
||||
violation_edges = [
|
||||
e for e in edges
|
||||
if e.get("source") == decision_id and e.get("type") in _VIOLATION_TYPES
|
||||
]
|
||||
|
||||
violation_types = {"violates", "non_compliant", "breaches"}
|
||||
violations = [
|
||||
{
|
||||
"policy_id": e.get("target"),
|
||||
"type": e.get("type"),
|
||||
"metadata": e.get("metadata", {}),
|
||||
"policy_id": edge.get("target"),
|
||||
"type": edge.get("type"),
|
||||
"metadata": edge.get("properties", {}),
|
||||
}
|
||||
for e in violation_edges
|
||||
for edge in edges
|
||||
if edge.get("source") == decision_id and edge.get("type") in violation_types
|
||||
]
|
||||
|
||||
return ComplianceResponse(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Enrichment & reasoning routes — extraction, link prediction, dedup, reasoning.
|
||||
"""
|
||||
Enrichment and reasoning routes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
@@ -14,12 +16,141 @@ from ..schemas import (
|
||||
EnrichExtractResponse,
|
||||
LinkPredictionRequest,
|
||||
LinkPredictionResponse,
|
||||
MergeRequest,
|
||||
MergeResponse,
|
||||
ReasoningRequest,
|
||||
ReasoningResponse,
|
||||
)
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(tags=["Enrichment"])
|
||||
_FACT_RE = re.compile(r"^(?P<predicate>[A-Za-z_][\w:-]*)\((?P<args>.*)\)$")
|
||||
|
||||
|
||||
def _safe_dict(obj) -> dict:
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
if hasattr(obj, "__dict__"):
|
||||
return {key: value for key, value in obj.__dict__.items() if not key.startswith("_")}
|
||||
return {"value": str(obj)}
|
||||
|
||||
|
||||
def _parse_fact(fact: str) -> Optional[Tuple[str, List[str]]]:
|
||||
match = _FACT_RE.match((fact or "").strip())
|
||||
if not match:
|
||||
return None
|
||||
args = [arg.strip().strip('"').strip("'") for arg in match.group("args").split(",") if arg.strip()]
|
||||
return match.group("predicate"), args
|
||||
|
||||
|
||||
def _parse_rule(rule: str) -> Optional[Tuple[List[Tuple[str, List[str]]], Tuple[str, List[str]]]]:
|
||||
cleaned = (rule or "").strip()
|
||||
if not cleaned.upper().startswith("IF ") or " THEN " not in cleaned.upper():
|
||||
return None
|
||||
upper = cleaned.upper()
|
||||
then_index = upper.index(" THEN ")
|
||||
antecedent_text = cleaned[3:then_index]
|
||||
consequent_text = cleaned[then_index + 6 :]
|
||||
antecedents = []
|
||||
for segment in re.split(r" AND ", " ".join(antecedent_text.split()), flags=re.IGNORECASE):
|
||||
parsed = _parse_fact(segment)
|
||||
if parsed is None:
|
||||
return None
|
||||
antecedents.append(parsed)
|
||||
consequent = _parse_fact(consequent_text)
|
||||
if consequent is None:
|
||||
return None
|
||||
return antecedents, consequent
|
||||
|
||||
|
||||
def _token_is_variable(token: str) -> bool:
|
||||
return token.startswith("?")
|
||||
|
||||
|
||||
def _match_pattern(pattern: Tuple[str, List[str]], fact: Tuple[str, List[str]], bindings: Dict[str, str]) -> Optional[Dict[str, str]]:
|
||||
pattern_predicate, pattern_args = pattern
|
||||
fact_predicate, fact_args = fact
|
||||
if pattern_predicate != fact_predicate or len(pattern_args) != len(fact_args):
|
||||
return None
|
||||
|
||||
next_bindings = dict(bindings)
|
||||
for pattern_arg, fact_arg in zip(pattern_args, fact_args):
|
||||
if _token_is_variable(pattern_arg):
|
||||
bound_value = next_bindings.get(pattern_arg)
|
||||
if bound_value is None:
|
||||
next_bindings[pattern_arg] = fact_arg
|
||||
elif bound_value != fact_arg:
|
||||
return None
|
||||
elif pattern_arg != fact_arg:
|
||||
return None
|
||||
return next_bindings
|
||||
|
||||
|
||||
def _instantiate(pattern: Tuple[str, List[str]], bindings: Dict[str, str]) -> str:
|
||||
predicate, args = pattern
|
||||
resolved = [bindings.get(arg, arg) for arg in args]
|
||||
return f"{predicate}({', '.join(resolved)})"
|
||||
|
||||
|
||||
def _run_fallback_reasoner(facts: List[str], rules: List[str]) -> List[str]:
|
||||
parsed_facts = [parsed for parsed in (_parse_fact(fact) for fact in facts) if parsed is not None]
|
||||
inferred: List[str] = []
|
||||
known = set(facts)
|
||||
|
||||
for rule in rules:
|
||||
parsed_rule = _parse_rule(rule)
|
||||
if parsed_rule is None:
|
||||
continue
|
||||
antecedents, consequent = parsed_rule
|
||||
bindings_list: List[Dict[str, str]] = [{}]
|
||||
for antecedent in antecedents:
|
||||
next_bindings: List[Dict[str, str]] = []
|
||||
for bindings in bindings_list:
|
||||
for fact in parsed_facts:
|
||||
matched = _match_pattern(antecedent, fact, bindings)
|
||||
if matched is not None:
|
||||
next_bindings.append(matched)
|
||||
bindings_list = next_bindings
|
||||
if not bindings_list:
|
||||
break
|
||||
for bindings in bindings_list:
|
||||
candidate = _instantiate(consequent, bindings)
|
||||
if candidate not in known:
|
||||
known.add(candidate)
|
||||
inferred.append(candidate)
|
||||
return inferred
|
||||
|
||||
|
||||
def _apply_inferred_edges(
|
||||
session: GraphSession,
|
||||
inferred_facts: List[str],
|
||||
body: ReasoningRequest,
|
||||
) -> int:
|
||||
added_edges = 0
|
||||
for fact in inferred_facts:
|
||||
parsed = _parse_fact(fact)
|
||||
if parsed is None:
|
||||
continue
|
||||
predicate, args = parsed
|
||||
if len(args) != 2:
|
||||
continue
|
||||
source, target = args
|
||||
if session.get_node(source) is None:
|
||||
session.graph.add_node(source, "entity", content=source)
|
||||
if session.get_node(target) is None:
|
||||
session.graph.add_node(target, "entity", content=target)
|
||||
edge_type = body.inferred_edge_type or predicate
|
||||
session.graph.add_edge(
|
||||
source,
|
||||
target,
|
||||
edge_type=edge_type,
|
||||
inferred=True,
|
||||
inferred_from=fact,
|
||||
reasoning_mode=body.mode,
|
||||
rules=list(body.rules),
|
||||
)
|
||||
added_edges += 1
|
||||
return added_edges
|
||||
|
||||
|
||||
@router.post("/api/enrich/extract", response_model=EnrichExtractResponse)
|
||||
@@ -27,7 +158,6 @@ async def extract_entities(
|
||||
body: EnrichExtractRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Extract entities and relations from free text."""
|
||||
try:
|
||||
from ...semantic_extract.methods import extract_entities as _extract_entities
|
||||
from ...semantic_extract.methods import extract_relations as _extract_relations
|
||||
@@ -39,13 +169,12 @@ async def extract_entities(
|
||||
rel_list = relations if isinstance(relations, list) else getattr(relations, "relations", [])
|
||||
|
||||
return EnrichExtractResponse(
|
||||
entities=[_safe_dict(e) for e in ent_list],
|
||||
relations=[_safe_dict(r) for r in rel_list],
|
||||
entities=[_safe_dict(entity) for entity in ent_list],
|
||||
relations=[_safe_dict(relation) for relation in rel_list],
|
||||
)
|
||||
except ImportError:
|
||||
raise ValueError(
|
||||
"semantic_extract module not available. "
|
||||
"Ensure spacy and transformers are installed."
|
||||
"semantic_extract module not available. Ensure spacy and transformers are installed."
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Extraction failed: {exc}")
|
||||
@@ -56,10 +185,9 @@ async def predict_links(
|
||||
body: LinkPredictionRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Predict likely new edges for a node."""
|
||||
predictor = session.link_predictor
|
||||
if predictor is None:
|
||||
raise ValueError("LinkPredictor not available — KG extras may not be installed.")
|
||||
raise ValueError("LinkPredictor not available; KG extras may not be installed.")
|
||||
|
||||
node = await asyncio.to_thread(session.get_node, body.node_id)
|
||||
if node is None:
|
||||
@@ -68,41 +196,38 @@ async def predict_links(
|
||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
|
||||
# Pre-compute already-connected node IDs so we skip them.
|
||||
# (LinkPredictor._edge_exists returns False for ContextGraph since it has no
|
||||
# has_edge/get_edge method, so we handle exclusion here instead.)
|
||||
existing_neighbours = {
|
||||
e.get("target") for e in edges if e.get("source") == body.node_id
|
||||
existing_neighbors = {
|
||||
edge.get("target") for edge in edges if edge.get("source") == body.node_id
|
||||
} | {
|
||||
e.get("source") for e in edges if e.get("target") == body.node_id
|
||||
edge.get("source") for edge in edges if edge.get("target") == body.node_id
|
||||
}
|
||||
|
||||
# Score each candidate via score_link (which works with ContextGraph because
|
||||
# it falls back to has_node / get_neighbors).
|
||||
# Run in a thread so the CPU-bound scoring loop never blocks the event loop.
|
||||
def _score_all() -> list:
|
||||
results = []
|
||||
for n in nodes:
|
||||
candidate = n.get("id")
|
||||
if not candidate or candidate == body.node_id or candidate in existing_neighbours:
|
||||
for candidate_node in nodes:
|
||||
candidate_id = candidate_node.get("id")
|
||||
if not candidate_id or candidate_id == body.node_id or candidate_id in existing_neighbors:
|
||||
continue
|
||||
if body.candidate_type and candidate_node.get("type") != body.candidate_type:
|
||||
continue
|
||||
try:
|
||||
score = predictor.score_link(session.graph, body.node_id, candidate)
|
||||
if score > 0:
|
||||
results.append(
|
||||
{"target": candidate, "score": score, "type": n.get("type", "entity")}
|
||||
)
|
||||
score = predictor.score_link(session.graph, body.node_id, candidate_id)
|
||||
except Exception:
|
||||
continue
|
||||
results.sort(key=lambda x: x["score"], reverse=True)
|
||||
if score >= body.min_score:
|
||||
results.append(
|
||||
{
|
||||
"target": candidate_id,
|
||||
"score": score,
|
||||
"type": candidate_node.get("type", "entity"),
|
||||
"label": candidate_node.get("content", candidate_id),
|
||||
}
|
||||
)
|
||||
results.sort(key=lambda item: item["score"], reverse=True)
|
||||
return results
|
||||
|
||||
scored = await asyncio.to_thread(_score_all)
|
||||
|
||||
return LinkPredictionResponse(
|
||||
node_id=body.node_id,
|
||||
predictions=scored[: body.top_n],
|
||||
)
|
||||
return LinkPredictionResponse(node_id=body.node_id, predictions=scored[: body.top_n])
|
||||
|
||||
|
||||
@router.post("/api/enrich/dedup", response_model=DedupResponse)
|
||||
@@ -110,32 +235,18 @@ async def detect_duplicates(
|
||||
body: DedupRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Run a deduplication scan over graph entities."""
|
||||
try:
|
||||
from ...deduplication import DuplicateDetector
|
||||
|
||||
detector = DuplicateDetector()
|
||||
# Use asyncio.to_thread — get_nodes acquires an RLock and must not block
|
||||
# the event loop.
|
||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
|
||||
entities = [
|
||||
{
|
||||
"id": n.get("id"),
|
||||
"text": n.get("content", n.get("id", "")),
|
||||
"type": n.get("type", "entity"),
|
||||
}
|
||||
for n in nodes
|
||||
{"id": node.get("id"), "text": node.get("content", node.get("id", "")), "type": node.get("type", "entity")}
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
dups = await asyncio.to_thread(
|
||||
detector.detect_duplicates, entities, threshold=body.threshold
|
||||
)
|
||||
dup_list = dups if isinstance(dups, list) else getattr(dups, "duplicates", [])
|
||||
return DedupResponse(
|
||||
duplicates=[_safe_dict(d) for d in dup_list],
|
||||
total_flagged=len(dup_list),
|
||||
)
|
||||
duplicates = await asyncio.to_thread(detector.detect_duplicates, entities, threshold=body.threshold)
|
||||
duplicate_list = duplicates if isinstance(duplicates, list) else getattr(duplicates, "duplicates", [])
|
||||
return DedupResponse(duplicates=[_safe_dict(item) for item in duplicate_list], total_flagged=len(duplicate_list))
|
||||
except ImportError:
|
||||
raise ValueError("Deduplication module not available.")
|
||||
except Exception as exc:
|
||||
@@ -147,29 +258,99 @@ async def run_reasoning(
|
||||
body: ReasoningRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Run inference rules over facts."""
|
||||
inferred_facts: List[str] = []
|
||||
try:
|
||||
from ...reasoning.reasoner import Reasoner
|
||||
|
||||
reasoner = Reasoner()
|
||||
inferred = await asyncio.to_thread(
|
||||
reasoner.infer_facts, body.facts, body.rules
|
||||
)
|
||||
|
||||
return ReasoningResponse(
|
||||
inferred_facts=inferred if isinstance(inferred, list) else [],
|
||||
rules_fired=len(inferred) if isinstance(inferred, list) else 0,
|
||||
)
|
||||
inferred = await asyncio.to_thread(reasoner.infer_facts, body.facts, body.rules)
|
||||
if isinstance(inferred, list):
|
||||
inferred_facts = inferred
|
||||
if not inferred_facts:
|
||||
inferred_facts = _run_fallback_reasoner(body.facts, body.rules)
|
||||
except ImportError:
|
||||
raise ValueError("Reasoning module not available.")
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Reasoning failed: {exc}")
|
||||
inferred_facts = _run_fallback_reasoner(body.facts, body.rules)
|
||||
except Exception:
|
||||
inferred_facts = _run_fallback_reasoner(body.facts, body.rules)
|
||||
|
||||
added_edges = 0
|
||||
if body.apply_to_graph and inferred_facts:
|
||||
added_edges = await asyncio.to_thread(_apply_inferred_edges, session, inferred_facts, body)
|
||||
|
||||
return ReasoningResponse(
|
||||
inferred_facts=inferred_facts,
|
||||
rules_fired=len(inferred_facts),
|
||||
added_edges=added_edges,
|
||||
mutated=added_edges > 0,
|
||||
)
|
||||
|
||||
|
||||
def _safe_dict(obj) -> dict:
|
||||
"""Convert an object to a JSON-safe dict."""
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
if hasattr(obj, "__dict__"):
|
||||
return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
|
||||
return {"value": str(obj)}
|
||||
@router.post("/api/enrich/merge", response_model=MergeResponse)
|
||||
async def merge_nodes(
|
||||
body: MergeRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
primary_id = body.primary_id
|
||||
duplicate_ids = body.duplicate_ids
|
||||
|
||||
node = await asyncio.to_thread(session.get_node, primary_id)
|
||||
if node is None:
|
||||
raise ValueError(f"Primary node {primary_id} not found")
|
||||
|
||||
def _do_merge() -> tuple[list[str], int]:
|
||||
removed: list[str] = []
|
||||
edges_updated = 0
|
||||
graph = session.graph
|
||||
|
||||
for duplicate_id in duplicate_ids:
|
||||
if duplicate_id == primary_id or duplicate_id not in graph:
|
||||
continue
|
||||
|
||||
duplicate_node = graph.nodes.get(duplicate_id)
|
||||
primary_node = graph.nodes.get(primary_id)
|
||||
if duplicate_node and primary_node:
|
||||
for key, value in (duplicate_node.properties or {}).items():
|
||||
if key not in (primary_node.properties or {}):
|
||||
primary_node.properties[key] = value
|
||||
primary_node.metadata[key] = value
|
||||
|
||||
edges_to_add = []
|
||||
retained_edges = []
|
||||
for edge in list(graph.edges):
|
||||
if edge.source_id == duplicate_id or edge.target_id == duplicate_id:
|
||||
new_source = primary_id if edge.source_id == duplicate_id else edge.source_id
|
||||
new_target = primary_id if edge.target_id == duplicate_id else edge.target_id
|
||||
if new_source != new_target:
|
||||
edges_to_add.append(
|
||||
{
|
||||
"source_id": new_source,
|
||||
"target_id": new_target,
|
||||
"type": edge.edge_type,
|
||||
"weight": edge.weight,
|
||||
"properties": edge.metadata,
|
||||
}
|
||||
)
|
||||
edges_updated += 1
|
||||
else:
|
||||
retained_edges.append(edge)
|
||||
|
||||
graph.edges = retained_edges
|
||||
graph._adjacency.pop(duplicate_id, None)
|
||||
for adjacency in graph._adjacency.values():
|
||||
adjacency[:] = [edge for edge in adjacency if edge.target_id != duplicate_id]
|
||||
graph.edge_type_index.clear()
|
||||
for edge in graph.edges:
|
||||
graph.edge_type_index[edge.edge_type].append(edge)
|
||||
|
||||
old_type = graph.nodes[duplicate_id].node_type
|
||||
graph.node_type_index.get(old_type, set()).discard(duplicate_id)
|
||||
del graph.nodes[duplicate_id]
|
||||
removed.append(duplicate_id)
|
||||
|
||||
if edges_to_add:
|
||||
graph.add_edges(edges_to_add)
|
||||
|
||||
return removed, edges_updated
|
||||
|
||||
removed_ids, edges_updated = await asyncio.to_thread(_do_merge)
|
||||
return MergeResponse(merged_into=primary_id, removed_ids=removed_ids, edges_updated=edges_updated)
|
||||
|
||||
@@ -1,77 +1,197 @@
|
||||
"""
|
||||
Export & import routes.
|
||||
"""
|
||||
Import and export routes for graph datasets.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..dependencies import get_session, get_ws_manager
|
||||
from ..schemas import ExportRequest
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import ExportRequest, ImportResponse
|
||||
from ..session import GraphSession
|
||||
from ..ws import ConnectionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Export / Import"])
|
||||
|
||||
|
||||
_FORMAT_MAP = {
|
||||
"json": ("export_json", "application/json", ".json"),
|
||||
"json-ld": ("export_json", "application/ld+json", ".jsonld"),
|
||||
"turtle": ("export_rdf", "text/turtle", ".ttl"),
|
||||
"rdf-xml": ("export_rdf", "application/rdf+xml", ".rdf"),
|
||||
"n-triples": ("export_rdf", "application/n-triples", ".nt"),
|
||||
"csv": ("export_csv", "text/csv", ".csv"),
|
||||
"graphml": ("export_graph", "application/xml", ".graphml"),
|
||||
"gexf": ("export_graph", "application/xml", ".gexf"),
|
||||
"owl": ("export_owl", "application/rdf+xml", ".owl"),
|
||||
"cypher": ("export_lpg", "text/plain", ".cypher"),
|
||||
"aql": ("export_arango", "text/plain", ".aql"),
|
||||
"yaml": ("export_yaml", "text/yaml", ".yaml"),
|
||||
}
|
||||
_IMPORT_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
# Only formats that the import handler actually parses.
|
||||
# Do not add extensions here unless a corresponding parsing branch exists below.
|
||||
_ALLOWED_IMPORT_EXTENSIONS = frozenset({".json", ".csv"})
|
||||
|
||||
|
||||
def _build_kg_dict(session: GraphSession, node_ids: Optional[list] = None) -> dict:
|
||||
"""Build the knowledge-graph dict that exporters expect."""
|
||||
nodes, _ = session.get_nodes(skip=0, limit=999_999)
|
||||
edges, _ = session.get_edges(skip=0, limit=999_999)
|
||||
def _import_response(nodes_added: int, edges_added: int, message: str = "Import successful") -> ImportResponse:
|
||||
return ImportResponse(
|
||||
status="success",
|
||||
message=message,
|
||||
nodes_added=nodes_added,
|
||||
edges_added=edges_added,
|
||||
nodes_imported=nodes_added,
|
||||
edges_imported=edges_added,
|
||||
)
|
||||
|
||||
if node_ids:
|
||||
id_set = set(node_ids)
|
||||
nodes = [n for n in nodes if n.get("id") in id_set]
|
||||
edges = [
|
||||
e for e in edges
|
||||
if e.get("source") in id_set and e.get("target") in id_set
|
||||
]
|
||||
|
||||
return {
|
||||
"entities": [
|
||||
{
|
||||
"id": n.get("id"),
|
||||
"type": n.get("type", "entity"),
|
||||
"text": n.get("content", n.get("id", "")),
|
||||
"metadata": n.get("metadata", {}),
|
||||
}
|
||||
for n in nodes
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"source": e.get("source"),
|
||||
"target": e.get("target"),
|
||||
"type": e.get("type", "related_to"),
|
||||
"metadata": e.get("metadata", {}),
|
||||
}
|
||||
for e in edges
|
||||
],
|
||||
}
|
||||
@router.post("/api/import", response_model=ImportResponse)
|
||||
async def import_file(
|
||||
file: UploadFile = File(...),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
import os as _os
|
||||
filename = (file.filename or "").lower()
|
||||
ext = _os.path.splitext(filename)[1]
|
||||
if ext not in _ALLOWED_IMPORT_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unsupported file type '{ext}'. Allowed: {sorted(_ALLOWED_IMPORT_EXTENSIONS)}",
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > _IMPORT_MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Upload exceeds the {_IMPORT_MAX_BYTES // (1024 * 1024)} MB limit.",
|
||||
)
|
||||
|
||||
if filename.endswith(".json"):
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid JSON file: {exc}") from exc
|
||||
|
||||
if isinstance(data, list):
|
||||
if data and any(key in data[0] for key in {"source", "source_id", "target", "target_id", "START_ID", "END_ID"}):
|
||||
raw_nodes = []
|
||||
raw_edges = data
|
||||
else:
|
||||
raw_nodes = data
|
||||
raw_edges = []
|
||||
elif isinstance(data, dict):
|
||||
raw_nodes = data.get("nodes", data.get("entities", []))
|
||||
raw_edges = data.get("edges", data.get("relationships", []))
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail="JSON import expects an object or array payload")
|
||||
|
||||
nodes = []
|
||||
for raw_node in raw_nodes:
|
||||
if "properties" in raw_node:
|
||||
nodes.append(raw_node)
|
||||
continue
|
||||
metadata = raw_node.get("metadata", {}) or {}
|
||||
nodes.append(
|
||||
{
|
||||
"id": str(raw_node.get("id", raw_node.get("_id", raw_node.get("node_id", "")))),
|
||||
"type": raw_node.get("type", "entity"),
|
||||
"properties": {
|
||||
"content": raw_node.get("text", raw_node.get("content", raw_node.get("id", ""))),
|
||||
**metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
edges = []
|
||||
for raw_edge in raw_edges:
|
||||
source = raw_edge.get("source") or raw_edge.get("source_id") or raw_edge.get("start") or raw_edge.get("start_id") or raw_edge.get("START_ID")
|
||||
target = raw_edge.get("target") or raw_edge.get("target_id") or raw_edge.get("end") or raw_edge.get("end_id") or raw_edge.get("END_ID")
|
||||
if not source or not target:
|
||||
continue
|
||||
edge_properties = raw_edge.get("metadata", raw_edge.get("properties", {})) or {}
|
||||
edges.append(
|
||||
{
|
||||
"id": raw_edge.get("id", raw_edge.get("edge_id")),
|
||||
"familyId": raw_edge.get("familyId", raw_edge.get("family_id")),
|
||||
"source_id": str(source),
|
||||
"target_id": str(target),
|
||||
"type": raw_edge.get("type", raw_edge.get("relationship", "related_to")),
|
||||
"weight": float(raw_edge.get("weight", 1.0)),
|
||||
"properties": edge_properties,
|
||||
"valid_from": raw_edge.get("valid_from", edge_properties.get("valid_from")),
|
||||
"valid_until": raw_edge.get("valid_until", edge_properties.get("valid_until")),
|
||||
}
|
||||
)
|
||||
|
||||
nodes_added = session.add_nodes(nodes)
|
||||
edges_added = session.add_edges(edges)
|
||||
return _import_response(nodes_added, edges_added)
|
||||
|
||||
if filename.endswith(".csv"):
|
||||
try:
|
||||
decoded = content.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HTTPException(status_code=422, detail="CSV file must be UTF-8 encoded") from exc
|
||||
|
||||
reader = csv.DictReader(io.StringIO(decoded))
|
||||
nodes = []
|
||||
edges = []
|
||||
for row in reader:
|
||||
source = row.get("source") or row.get("source_id") or row.get(":START_ID") or row.get("START_ID")
|
||||
target = row.get("target") or row.get("target_id") or row.get(":END_ID") or row.get("END_ID")
|
||||
node_id = row.get("id") or row.get("node_id") or row.get(":ID") or row.get("_id") or row.get("ID")
|
||||
|
||||
if source and target:
|
||||
edge_props = {
|
||||
key: value
|
||||
for key, value in row.items()
|
||||
if key not in {
|
||||
"id",
|
||||
"edge_id",
|
||||
"familyId",
|
||||
"family_id",
|
||||
"source",
|
||||
"source_id",
|
||||
"target",
|
||||
"target_id",
|
||||
"type",
|
||||
"relationship",
|
||||
"weight",
|
||||
":START_ID",
|
||||
"START_ID",
|
||||
":END_ID",
|
||||
"END_ID",
|
||||
":TYPE",
|
||||
}
|
||||
}
|
||||
edges.append(
|
||||
{
|
||||
"id": row.get("id") or row.get("edge_id"),
|
||||
"familyId": row.get("familyId") or row.get("family_id"),
|
||||
"source_id": str(source),
|
||||
"target_id": str(target),
|
||||
"type": row.get("type") or row.get("relationship") or row.get(":TYPE") or "related_to",
|
||||
"weight": float(row.get("weight", 1.0) or 1.0),
|
||||
"properties": edge_props,
|
||||
}
|
||||
)
|
||||
elif node_id:
|
||||
node_props = {
|
||||
key: value
|
||||
for key, value in row.items()
|
||||
if key not in {"id", "node_id", "type", "label", ":ID", "_id", "ID", ":LABEL"}
|
||||
}
|
||||
nodes.append(
|
||||
{
|
||||
"id": str(node_id),
|
||||
"type": row.get("type") or row.get("label") or row.get(":LABEL") or "entity",
|
||||
"properties": node_props,
|
||||
}
|
||||
)
|
||||
|
||||
if not nodes and not edges:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="No valid nodes or edges could be parsed from the CSV payload.",
|
||||
)
|
||||
|
||||
nodes_added = session.add_nodes(nodes)
|
||||
edges_added = session.add_edges(edges)
|
||||
return _import_response(nodes_added, edges_added)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unsupported file type '{_os.path.splitext(filename)[1]}'. Allowed: {sorted(_ALLOWED_IMPORT_EXTENSIONS)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/export")
|
||||
@@ -79,160 +199,40 @@ async def export_graph(
|
||||
body: ExportRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Export the current graph in the requested format."""
|
||||
fmt = body.format.lower()
|
||||
if fmt not in _FORMAT_MAP:
|
||||
raise ValueError(
|
||||
f"Unsupported format '{fmt}'. Supported: {', '.join(sorted(_FORMAT_MAP))}"
|
||||
)
|
||||
graph_dict = session.build_graph_dict(body.node_ids)
|
||||
|
||||
func_name, content_type, ext = _FORMAT_MAP[fmt]
|
||||
if fmt == "json":
|
||||
content = json.dumps(graph_dict, indent=2, default=str)
|
||||
media_type = "application/json"
|
||||
extension = "json"
|
||||
elif fmt == "csv":
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
kg = await asyncio.to_thread(_build_kg_dict, session, body.node_ids)
|
||||
writer.writerow(["kind", "id", "familyId", "type", "content", "source", "target", "weight"])
|
||||
for node in graph_dict.get("entities", []):
|
||||
writer.writerow(["node", node.get("id"), "", node.get("type"), node.get("text"), "", "", ""])
|
||||
for edge in graph_dict.get("relationships", []):
|
||||
writer.writerow([
|
||||
"edge",
|
||||
edge.get("id"),
|
||||
edge.get("familyId"),
|
||||
edge.get("type"),
|
||||
"",
|
||||
edge.get("source"),
|
||||
edge.get("target"),
|
||||
edge.get("metadata", {}).get("weight", edge.get("weight", "")),
|
||||
])
|
||||
|
||||
kg = await asyncio.to_thread(session.build_graph_dict, body.node_ids)
|
||||
content = output.getvalue()
|
||||
media_type = "text/csv"
|
||||
extension = "csv"
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail=f"Unsupported export format '{fmt}'")
|
||||
|
||||
try:
|
||||
from ...export.methods import (
|
||||
export_json, export_rdf, export_csv, export_graph as export_graph_fn,
|
||||
export_owl, export_lpg, export_arango, export_yaml,
|
||||
)
|
||||
fn_map = {
|
||||
"export_json": export_json,
|
||||
"export_rdf": export_rdf,
|
||||
"export_csv": export_csv,
|
||||
"export_graph": export_graph_fn,
|
||||
"export_owl": export_owl,
|
||||
"export_lpg": export_lpg,
|
||||
"export_arango": export_arango,
|
||||
"export_yaml": export_yaml,
|
||||
}
|
||||
export_fn = fn_map.get(func_name)
|
||||
if export_fn is None:
|
||||
raise ValueError(f"Export function {func_name} not found.")
|
||||
|
||||
# Write to a temp file, read back content.
|
||||
with tempfile.NamedTemporaryFile(suffix=ext, delete=False, mode="w") as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
await asyncio.to_thread(export_fn, kg, tmp_path)
|
||||
|
||||
with open(tmp_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
content = fh.read()
|
||||
|
||||
import os
|
||||
os.unlink(tmp_path)
|
||||
|
||||
except ImportError:
|
||||
|
||||
# Write to a temp file; always clean up even if export or read fails.
|
||||
tmp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=ext, delete=False, mode="w") as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
await asyncio.to_thread(export_fn, kg, tmp_path)
|
||||
|
||||
with open(tmp_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
content = fh.read()
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
except ImportError:
|
||||
content = json.dumps(kg, indent=2, default=str)
|
||||
content_type = "application/json"
|
||||
ext = ".json"
|
||||
|
||||
filename = f"semantica_export{ext}"
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=content_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="semantica_export.{extension}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/import")
|
||||
async def import_file(
|
||||
file: UploadFile = File(...),
|
||||
session: GraphSession = Depends(get_session),
|
||||
ws: ConnectionManager = Depends(get_ws_manager),
|
||||
):
|
||||
"""
|
||||
Import entities from an uploaded file (JSON or CSV).
|
||||
|
||||
For JSON files the expected shape is ``{"nodes": [...], "edges": [...]}``.
|
||||
"""
|
||||
content = await file.read()
|
||||
filename = file.filename or "upload"
|
||||
|
||||
await ws.broadcast("import_started", {"filename": filename})
|
||||
|
||||
try:
|
||||
if filename.endswith(".json") or filename.endswith(".jsonld"):
|
||||
data = json.loads(content)
|
||||
|
||||
|
||||
raw_nodes = data.get("nodes", data.get("entities", []))
|
||||
raw_edges = data.get("edges", data.get("relationships", []))
|
||||
|
||||
|
||||
# KG export uses {id, type, text, metadata}
|
||||
# ContextGraph.add_nodes expects {id, type, properties: {content, ...}}
|
||||
nodes = []
|
||||
for n in raw_nodes:
|
||||
if "properties" in n:
|
||||
nodes.append(n)
|
||||
else:
|
||||
nodes.append({
|
||||
"id": n.get("id"),
|
||||
"type": n.get("type", "entity"),
|
||||
"properties": {
|
||||
"content": n.get("text", n.get("content", n.get("id", ""))),
|
||||
**(n.get("metadata") or {}),
|
||||
},
|
||||
})
|
||||
|
||||
# KG export uses {source, target, type, metadata}
|
||||
# ContextGraph.add_edges expects {source_id, target_id, type, weight, properties}
|
||||
edges = []
|
||||
for r in raw_edges:
|
||||
src = r.get("source_id", r.get("source"))
|
||||
tgt = r.get("target_id", r.get("target"))
|
||||
if not src or not tgt:
|
||||
continue
|
||||
edges.append({
|
||||
"source_id": src,
|
||||
"target_id": tgt,
|
||||
"type": r.get("type", "related_to"),
|
||||
"weight": r.get("weight", 1.0),
|
||||
"properties": r.get("metadata") or r.get("properties") or {},
|
||||
})
|
||||
nodes = data.get("nodes", data.get("entities", []))
|
||||
edges = data.get("edges", data.get("relationships", []))
|
||||
|
||||
for edge in edges:
|
||||
if "source" in edge and "source_id" not in edge:
|
||||
edge["source_id"] = edge["source"]
|
||||
if "target" in edge and "target_id" not in edge:
|
||||
edge["target_id"] = edge["target"]
|
||||
|
||||
added_nodes = await asyncio.to_thread(session.add_nodes, nodes)
|
||||
added_edges = await asyncio.to_thread(session.add_edges, edges)
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"nodes_added": added_nodes,
|
||||
"edges_added": added_edges,
|
||||
}
|
||||
else:
|
||||
result = {
|
||||
"status": "unsupported",
|
||||
"detail": f"File type not supported yet: {filename}",
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.exception("Import failed")
|
||||
result = {"status": "error", "detail": "An internal error occurred during import"}
|
||||
|
||||
await ws.broadcast("import_completed", result)
|
||||
return result
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
Graph routes — node / edge / path / search endpoints.
|
||||
"""
|
||||
Graph routes for explorer node, edge, path, and search APIs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
@@ -25,37 +26,53 @@ from ..session import GraphSession
|
||||
router = APIRouter(prefix="/api/graph", tags=["Graph"])
|
||||
|
||||
|
||||
def _node_dict_to_response(n: dict) -> NodeResponse:
|
||||
"""Convert a ContextGraph node dict to a NodeResponse."""
|
||||
meta = n.get("metadata", {})
|
||||
return NodeResponse(
|
||||
id=n.get("id", ""),
|
||||
type=n.get("type", "entity"),
|
||||
content=n.get("content", meta.get("content", "")),
|
||||
properties=meta,
|
||||
valid_from=meta.get("valid_from"),
|
||||
valid_until=meta.get("valid_until"),
|
||||
)
|
||||
def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float, float]]:
|
||||
if not raw_bbox:
|
||||
return None
|
||||
parts = [part.strip() for part in raw_bbox.split(",")]
|
||||
if len(parts) != 4:
|
||||
raise ValueError("bbox must be four comma-separated numbers: min_x,min_y,max_x,max_y")
|
||||
min_x, min_y, max_x, max_y = [float(part) for part in parts]
|
||||
if min_x > max_x or min_y > max_y:
|
||||
raise ValueError("bbox minimum values must be less than or equal to maximum values")
|
||||
return min_x, min_y, max_x, max_y
|
||||
|
||||
|
||||
def _node_response(node: dict) -> NodeResponse:
|
||||
return NodeResponse(**node)
|
||||
|
||||
|
||||
def _edge_response(edge: dict) -> EdgeResponse:
|
||||
return EdgeResponse(**edge)
|
||||
|
||||
|
||||
@router.get("/nodes", response_model=NodeListResponse)
|
||||
async def list_nodes(
|
||||
type: Optional[str] = Query(None, description="Filter by node type"),
|
||||
search: Optional[str] = Query(None, description="Keyword search over node content"),
|
||||
bbox: Optional[str] = Query(None, description="Viewport filter: min_x,min_y,max_x,max_y"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
limit: int = Query(100, ge=1, le=5000),
|
||||
cursor: Optional[str] = Query(None, description="Opaque cursor for forward pagination"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""List nodes with optional filtering and pagination."""
|
||||
nodes, total = await asyncio.to_thread(
|
||||
session.get_nodes, node_type=type, search=search, skip=skip, limit=limit
|
||||
parsed_bbox = _parse_bbox(bbox)
|
||||
nodes, total, next_cursor = await asyncio.to_thread(
|
||||
session.paginate_nodes,
|
||||
node_type=type,
|
||||
search=search,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
bbox=parsed_bbox,
|
||||
)
|
||||
return NodeListResponse(
|
||||
nodes=[_node_dict_to_response(n) for n in nodes],
|
||||
nodes=[_node_response(node) for node in nodes],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
next_cursor=next_cursor,
|
||||
has_more=next_cursor is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,11 +81,10 @@ async def get_node(
|
||||
node_id: str,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Get a single node by ID."""
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise KeyError(node_id)
|
||||
return _node_dict_to_response(node)
|
||||
return _node_response(node)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/neighbors", response_model=list[NeighborResponse])
|
||||
@@ -77,146 +93,103 @@ async def get_neighbors(
|
||||
depth: int = Query(1, ge=1, le=5),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Get neighbours of a node via BFS traversal."""
|
||||
neighbors = await asyncio.to_thread(session.get_neighbors, node_id, depth)
|
||||
return [
|
||||
NeighborResponse(
|
||||
id=nb.get("id", ""),
|
||||
type=nb.get("type", ""),
|
||||
content=nb.get("content", ""),
|
||||
relationship=nb.get("relationship", ""),
|
||||
weight=nb.get("weight", 1.0),
|
||||
hop=nb.get("hop", 1),
|
||||
id=neighbor.get("id", ""),
|
||||
type=neighbor.get("type", ""),
|
||||
content=neighbor.get("content", ""),
|
||||
relationship=neighbor.get("relationship", ""),
|
||||
weight=neighbor.get("weight", 1.0),
|
||||
hop=neighbor.get("hop", 1),
|
||||
)
|
||||
for nb in neighbors
|
||||
for neighbor in neighbors
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/edges", response_model=EdgeListResponse)
|
||||
async def list_edges(
|
||||
type: Optional[str] = Query(None, description="Filter by edge type"),
|
||||
source: Optional[str] = Query(None, description="Filter by source node ID"),
|
||||
target: Optional[str] = Query(None, description="Filter by target node ID"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
limit: int = Query(100, ge=1, le=5000),
|
||||
cursor: Optional[str] = Query(None, description="Opaque cursor for forward pagination"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""List edges with optional filtering and pagination."""
|
||||
edges, total = await asyncio.to_thread(
|
||||
session.get_edges, edge_type=type, source=source, target=target, skip=skip, limit=limit
|
||||
edges, total, next_cursor = await asyncio.to_thread(
|
||||
session.paginate_edges,
|
||||
edge_type=type,
|
||||
source=source,
|
||||
target=target,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
return EdgeListResponse(
|
||||
edges=[
|
||||
EdgeResponse(
|
||||
source=e.get("source", ""),
|
||||
target=e.get("target", ""),
|
||||
type=e.get("type", ""),
|
||||
weight=e.get("weight", 1.0),
|
||||
properties=e.get("metadata", {}),
|
||||
)
|
||||
for e in edges
|
||||
],
|
||||
edges=[_edge_response(edge) for edge in edges],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
next_cursor=next_cursor,
|
||||
has_more=next_cursor is not None,
|
||||
)
|
||||
|
||||
|
||||
class _PathAlgorithm(str, Enum):
|
||||
bfs = "bfs"
|
||||
dijkstra = "dijkstra"
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/path", response_model=PathResponse)
|
||||
async def find_path(
|
||||
node_id: str,
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
algorithm: str = Query("bfs", description="Algorithm: bfs, dijkstra"),
|
||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Find a path between two nodes."""
|
||||
pf = session.path_finder
|
||||
if pf is None:
|
||||
raise ValueError("PathFinder not available — KG extras may not be installed.")
|
||||
path_finder = session.path_finder
|
||||
if path_finder is None:
|
||||
raise ValueError("PathFinder not available; KG extras may not be installed.")
|
||||
|
||||
graph_data = await asyncio.to_thread(_build_graph_dict, session)
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
pf.find_shortest_path, graph_data, node_id, target
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
path_fn = (
|
||||
path_finder.dijkstra_shortest_path
|
||||
if algorithm == _PathAlgorithm.dijkstra
|
||||
else path_finder.bfs_shortest_path
|
||||
)
|
||||
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else []
|
||||
graph_data = await asyncio.to_thread(session.build_graph_dict)
|
||||
|
||||
# Select algorithm: dijkstra for weighted shortest path, bfs otherwise.
|
||||
if algorithm.lower() == "dijkstra":
|
||||
path_fn = pf.dijkstra_shortest_path
|
||||
else:
|
||||
path_fn = pf.bfs_shortest_path
|
||||
|
||||
result = await asyncio.to_thread(path_fn, graph_data, node_id, target)
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target)
|
||||
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0
|
||||
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
|
||||
|
||||
return PathResponse(
|
||||
source=node_id,
|
||||
target=target,
|
||||
algorithm=algorithm,
|
||||
algorithm=algorithm.value,
|
||||
path=path_nodes,
|
||||
edge_ids=edge_ids,
|
||||
total_weight=total_weight,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/search", response_model=SearchResultResponse)
|
||||
async def search_nodes(
|
||||
body: SearchRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Keyword search over graph nodes."""
|
||||
results = await asyncio.to_thread(session.search, body.query, body.limit)
|
||||
results = await asyncio.to_thread(session.search, body.query, body.limit, body.filters)
|
||||
items = [
|
||||
SearchResultItem(
|
||||
node=_node_dict_to_response(r.get("node", {})),
|
||||
score=r.get("score", 0.0),
|
||||
)
|
||||
for r in results
|
||||
SearchResultItem(node=_node_response(result.get("node", {})), score=result.get("score", 0.0))
|
||||
for result in results
|
||||
]
|
||||
return SearchResultResponse(results=items, total=len(items), query=body.query)
|
||||
|
||||
|
||||
|
||||
@router.get("/stats", response_model=GraphStatsResponse)
|
||||
async def graph_stats(
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Get graph-level statistics."""
|
||||
stats = await asyncio.to_thread(session.get_stats)
|
||||
return GraphStatsResponse(**stats)
|
||||
|
||||
|
||||
|
||||
def _build_graph_dict(session: GraphSession) -> dict:
|
||||
"""Build a graph dict for analytics helpers (entities + relationships)."""
|
||||
nodes, _ = session.get_nodes(skip=0, limit=999_999)
|
||||
edges, _ = session.get_edges(skip=0, limit=999_999)
|
||||
return {
|
||||
"entities": [
|
||||
{
|
||||
"id": n.get("id"),
|
||||
"type": n.get("type", "entity"),
|
||||
"text": n.get("content", n.get("id", "")),
|
||||
"metadata": n.get("metadata", {}),
|
||||
}
|
||||
for n in nodes
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"source": e.get("source"),
|
||||
"target": e.get("target"),
|
||||
"type": e.get("type", "related_to"),
|
||||
"metadata": e.get("metadata", {}),
|
||||
}
|
||||
for e in edges
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Provenance routes for lineage visualization and exportable reports.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import networkx as nx
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/provenance", tags=["Power User Tools"])
|
||||
|
||||
|
||||
class ProvenanceNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
prov_type: str
|
||||
parent_id: str
|
||||
|
||||
|
||||
class ProvenanceEdge(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
label: str
|
||||
|
||||
|
||||
class ProvenanceResponse(BaseModel):
|
||||
nodes: List[ProvenanceNode]
|
||||
edges: List[ProvenanceEdge]
|
||||
|
||||
|
||||
_AGENT_TYPES = {"person", "organization", "system", "agent"}
|
||||
_ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"}
|
||||
|
||||
|
||||
def _classify_prov(node_type: str) -> tuple[str, str]:
|
||||
lowered = node_type.lower()
|
||||
if lowered in _AGENT_TYPES:
|
||||
return "Agent", "group_agent"
|
||||
if lowered in _ACTIVITY_TYPES:
|
||||
return "Activity", "group_activity"
|
||||
return "Entity", "group_entity"
|
||||
|
||||
|
||||
def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> dict:
|
||||
if not node_id or node_id not in session.graph.nodes:
|
||||
return {"nodes": [], "edges": []}
|
||||
|
||||
graph = nx.DiGraph()
|
||||
graph.add_node(node_id)
|
||||
|
||||
hop_nodes = {node_id}
|
||||
for edge in session.graph.edges:
|
||||
if edge.source_id == node_id or edge.target_id == node_id:
|
||||
graph.add_edge(edge.source_id, edge.target_id, label=edge.edge_type)
|
||||
hop_nodes.add(edge.source_id)
|
||||
hop_nodes.add(edge.target_id)
|
||||
|
||||
for edge in session.graph.edges:
|
||||
if edge.source_id in hop_nodes or edge.target_id in hop_nodes:
|
||||
graph.add_edge(edge.source_id, edge.target_id, label=edge.edge_type)
|
||||
|
||||
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=False)
|
||||
provenance_nodes: List[Dict[str, Any]] = []
|
||||
for graph_node_id in subgraph.nodes():
|
||||
node = session.graph.nodes.get(graph_node_id)
|
||||
if node is None:
|
||||
continue
|
||||
prov_type, parent_id = _classify_prov(node.node_type)
|
||||
provenance_nodes.append(
|
||||
{
|
||||
"id": graph_node_id,
|
||||
"label": node.content or graph_node_id,
|
||||
"prov_type": prov_type,
|
||||
"parent_id": parent_id,
|
||||
}
|
||||
)
|
||||
|
||||
provenance_edges: List[Dict[str, Any]] = []
|
||||
for source, target, data in subgraph.edges(data=True):
|
||||
provenance_edges.append(
|
||||
{
|
||||
"id": f"{source}-{target}",
|
||||
"source": source,
|
||||
"target": target,
|
||||
"label": data.get("label", "related_to"),
|
||||
}
|
||||
)
|
||||
|
||||
return {"nodes": provenance_nodes, "edges": provenance_edges}
|
||||
|
||||
|
||||
def _build_report(session: GraphSession, node_id: str) -> Dict[str, Any]:
|
||||
node = session.get_node(node_id)
|
||||
provenance = _build_provenance(session, node_id)
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"label": node.get("content", node_id) if node else node_id,
|
||||
"type": node.get("type", "entity") if node else "entity",
|
||||
"properties": node.get("properties", {}) if node else {},
|
||||
"lineage": provenance,
|
||||
}
|
||||
|
||||
|
||||
def _render_markdown(report: Dict[str, Any]) -> str:
|
||||
lines = [
|
||||
f"# Provenance Report: {report['label']}",
|
||||
"",
|
||||
f"- Node ID: `{report['node_id']}`",
|
||||
f"- Type: `{report['type']}`",
|
||||
"",
|
||||
"## Properties",
|
||||
]
|
||||
properties = report.get("properties", {})
|
||||
if properties:
|
||||
for key, value in properties.items():
|
||||
lines.append(f"- **{key}**: {value}")
|
||||
else:
|
||||
lines.append("- No properties recorded")
|
||||
|
||||
lines.extend(["", "## Lineage Nodes"])
|
||||
for node in report.get("lineage", {}).get("nodes", []):
|
||||
lines.append(f"- `{node['id']}` ({node['prov_type']}): {node['label']}")
|
||||
|
||||
lines.extend(["", "## Lineage Edges"])
|
||||
for edge in report.get("lineage", {}).get("edges", []):
|
||||
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.get("", response_model=ProvenanceResponse)
|
||||
@router.get("/", response_model=ProvenanceResponse, include_in_schema=False)
|
||||
async def get_provenance_lineage(
|
||||
node_id: Optional[str] = None,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
data = await asyncio.to_thread(_build_provenance, session, node_id)
|
||||
return ProvenanceResponse(
|
||||
nodes=[ProvenanceNode(**node) for node in data["nodes"]],
|
||||
edges=[ProvenanceEdge(**edge) for edge in data["edges"]],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/report")
|
||||
async def export_provenance_report(
|
||||
node_id: str = Query(..., description="Node ID to export"),
|
||||
format: str = Query("json", description="json or markdown"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
report = await asyncio.to_thread(_build_report, session, node_id)
|
||||
if format.lower() in {"md", "markdown"}:
|
||||
content = _render_markdown(report)
|
||||
return PlainTextResponse(
|
||||
content,
|
||||
headers={"Content-Disposition": f'attachment; filename="{node_id}_provenance.md"'},
|
||||
)
|
||||
|
||||
content = json.dumps(report, indent=2, default=str)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{node_id}_provenance.json"'},
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
SPARQL routes backed by an in-memory rdflib projection of the current graph.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import rdflib
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/sparql", tags=["Power User Tools"])
|
||||
|
||||
_ALLOWED_QUERY_TYPES = re.compile(
|
||||
r"^\s*(SELECT|ASK|CONSTRUCT|DESCRIBE)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_read_only_query(query: str) -> bool:
|
||||
"""Return True only for SELECT / ASK / CONSTRUCT / DESCRIBE queries."""
|
||||
return bool(_ALLOWED_QUERY_TYPES.match(query))
|
||||
|
||||
|
||||
class SparqlRequest(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
class SparqlResponse(BaseModel):
|
||||
columns: List[str]
|
||||
rows: List[Dict[str, Any]]
|
||||
total: int
|
||||
truncated: bool = False # True when _SPARQL_MAX_ROWS was hit
|
||||
error: Optional[str] = None
|
||||
error_line: Optional[int] = None
|
||||
error_column: Optional[int] = None
|
||||
|
||||
|
||||
NS = rdflib.Namespace("http://semantica.local/entity/")
|
||||
PROP = rdflib.Namespace("http://semantica.local/prop/")
|
||||
|
||||
|
||||
def _build_rdflib_graph(session: GraphSession) -> rdflib.Graph:
|
||||
graph = rdflib.Graph()
|
||||
graph.bind("ent", NS)
|
||||
graph.bind("prop", PROP)
|
||||
|
||||
nodes, _ = session.get_nodes(skip=0, limit=999_999)
|
||||
edges, _ = session.get_edges(skip=0, limit=999_999)
|
||||
|
||||
for node in nodes:
|
||||
subject = NS[str(node.get("id", ""))]
|
||||
node_type = node.get("type", "Entity")
|
||||
graph.add((subject, rdflib.RDF.type, NS[node_type]))
|
||||
|
||||
content = node.get("content", "")
|
||||
if content:
|
||||
graph.add((subject, rdflib.RDFS.label, rdflib.Literal(content)))
|
||||
|
||||
for key, value in node.get("properties", {}).items():
|
||||
if key in {"content", "valid_from", "valid_until"}:
|
||||
continue
|
||||
graph.add((subject, PROP[key], rdflib.Literal(value)))
|
||||
|
||||
for edge in edges:
|
||||
source = NS[str(edge.get("source", ""))]
|
||||
target = NS[str(edge.get("target", ""))]
|
||||
relationship = edge.get("type", "relatedTo")
|
||||
graph.add((source, PROP[relationship], target))
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
_SPARQL_MAX_ROWS = 5_000 # hard cap on returned rows
|
||||
_SPARQL_TIMEOUT_S = 30 # seconds before abandoning the await
|
||||
_SPARQL_MAX_CONCURRENT = 4 # semaphore: max simultaneous executions
|
||||
|
||||
# Semaphore caps how many graph.query calls run concurrently so that
|
||||
# timed-out threads (which keep running in the pool) cannot crowd out
|
||||
# other requests by exhausting the default ThreadPoolExecutor workers.
|
||||
_sparql_semaphore = asyncio.Semaphore(_SPARQL_MAX_CONCURRENT)
|
||||
|
||||
|
||||
@router.post("", response_model=SparqlResponse)
|
||||
async def execute_sparql(
|
||||
req: SparqlRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
if not _is_read_only_query(req.query):
|
||||
return SparqlResponse(
|
||||
columns=[],
|
||||
rows=[],
|
||||
total=0,
|
||||
error="Only SELECT, ASK, CONSTRUCT, and DESCRIBE queries are permitted.",
|
||||
)
|
||||
|
||||
graph = await asyncio.to_thread(_build_rdflib_graph, session)
|
||||
|
||||
async with _sparql_semaphore:
|
||||
try:
|
||||
query_results = await asyncio.wait_for(
|
||||
asyncio.to_thread(graph.query, req.query),
|
||||
timeout=_SPARQL_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return SparqlResponse(
|
||||
columns=[],
|
||||
rows=[],
|
||||
total=0,
|
||||
error=f"Query timed out after {_SPARQL_TIMEOUT_S} seconds.",
|
||||
)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
line_match = re.search(r"line[\s:]+(\d+)", error, re.IGNORECASE)
|
||||
column_match = re.search(r"col(?:umn)?[\s:]+(\d+)", error, re.IGNORECASE)
|
||||
return SparqlResponse(
|
||||
columns=[],
|
||||
rows=[],
|
||||
total=0,
|
||||
error=error,
|
||||
error_line=int(line_match.group(1)) if line_match else None,
|
||||
error_column=int(column_match.group(1)) if column_match else None,
|
||||
)
|
||||
|
||||
columns = [str(var) for var in query_results.vars] if query_results.vars else []
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for row in query_results:
|
||||
if len(rows) >= _SPARQL_MAX_ROWS:
|
||||
break
|
||||
row_data = {}
|
||||
for index, column in enumerate(columns):
|
||||
value = row[index]
|
||||
row_data[column] = str(value) if value is not None else None
|
||||
rows.append(row_data)
|
||||
|
||||
truncated = len(rows) == _SPARQL_MAX_ROWS
|
||||
return SparqlResponse(columns=columns, rows=rows, total=len(rows), truncated=truncated)
|
||||
@@ -1,91 +1,96 @@
|
||||
"""
|
||||
Temporal routes — snapshot, diff, patterns.
|
||||
"""
|
||||
Temporal routes for snapshots, diffs, and pattern detection.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone, UTC
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import (
|
||||
NodeResponse,
|
||||
TemporalDiffResponse,
|
||||
TemporalPatternResponse,
|
||||
TemporalSnapshotResponse,
|
||||
)
|
||||
from ..schemas import TemporalDiffResponse, TemporalPatternResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/temporal", tags=["Temporal"])
|
||||
|
||||
|
||||
def _node_dict_to_response(n: dict) -> NodeResponse:
|
||||
meta = n.get("metadata", {})
|
||||
return NodeResponse(
|
||||
id=n.get("id", ""),
|
||||
type=n.get("type", "entity"),
|
||||
content=n.get("content", meta.get("content", "")),
|
||||
properties=meta,
|
||||
valid_from=meta.get("valid_from"),
|
||||
valid_until=meta.get("valid_until"),
|
||||
)
|
||||
class TemporalSnapshotFastResponse(BaseModel):
|
||||
timestamp: str
|
||||
active_node_ids: List[str]
|
||||
active_node_count: int
|
||||
|
||||
|
||||
@router.get("/snapshot", response_model=TemporalSnapshotResponse)
|
||||
class TemporalBoundsResponse(BaseModel):
|
||||
min: Optional[str] = None
|
||||
max: Optional[str] = None
|
||||
|
||||
|
||||
def _parse_flexible_dt(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if re.fullmatch(r"\d{4}", text):
|
||||
text = f"{text}-01-01"
|
||||
text = text.replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.warning("Malformed temporal value %r; treating node as always active (%s)", value, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_query_dt(value: str) -> datetime:
|
||||
parsed = _parse_flexible_dt(value)
|
||||
if parsed is None:
|
||||
logger.warning("Could not parse timestamp %r; defaulting to utcnow()", value)
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
@router.get("/snapshot", response_model=TemporalSnapshotFastResponse)
|
||||
async def temporal_snapshot(
|
||||
at: Optional[str] = Query(
|
||||
None, description="ISO-8601 datetime; defaults to now."
|
||||
),
|
||||
at: Optional[str] = Query(None, description="ISO datetime or year; defaults to now"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Return the graph as it existed at a given timestamp.
|
||||
|
||||
Only nodes whose ``valid_from`` / ``valid_until`` window includes
|
||||
the requested time are returned.
|
||||
"""
|
||||
if at:
|
||||
ts_str = at.replace("Z", "+00:00")
|
||||
at_time = datetime.fromisoformat(ts_str)
|
||||
else:
|
||||
at_time = datetime.now(timezone.utc)
|
||||
|
||||
active = await asyncio.to_thread(session.get_active_nodes, at_time=at_time)
|
||||
return TemporalSnapshotResponse(
|
||||
at_time = _parse_query_dt(at) if at else datetime.now(UTC).replace(tzinfo=None)
|
||||
active_nodes = await asyncio.to_thread(session.get_active_nodes, at_time=at_time)
|
||||
active_ids = [node.get("id") for node in active_nodes if node.get("id")]
|
||||
return TemporalSnapshotFastResponse(
|
||||
timestamp=at_time.isoformat(),
|
||||
active_nodes=[_node_dict_to_response(n) for n in active],
|
||||
active_node_count=len(active),
|
||||
active_node_ids=active_ids,
|
||||
active_node_count=len(active_ids),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/diff", response_model=TemporalDiffResponse)
|
||||
async def temporal_diff(
|
||||
from_time: str = Query(..., description="Start ISO-8601 datetime"),
|
||||
to_time: str = Query(..., description="End ISO-8601 datetime"),
|
||||
from_time: str = Query(..., description="Start ISO datetime"),
|
||||
to_time: str = Query(..., description="End ISO datetime"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Diff the graph between two points in time.
|
||||
start = _parse_query_dt(from_time)
|
||||
end = _parse_query_dt(to_time)
|
||||
|
||||
Returns node IDs that were added (active at ``to_time`` but not
|
||||
``from_time``) and removed (active at ``from_time`` but not
|
||||
``to_time``).
|
||||
"""
|
||||
t1 = datetime.fromisoformat(from_time.replace("Z", "+00:00"))
|
||||
t2 = datetime.fromisoformat(to_time.replace("Z", "+00:00"))
|
||||
|
||||
active_t1 = await asyncio.to_thread(session.get_active_nodes, at_time=t1)
|
||||
active_t2 = await asyncio.to_thread(session.get_active_nodes, at_time=t2)
|
||||
|
||||
ids_t1 = {n.get("id") for n in active_t1}
|
||||
ids_t2 = {n.get("id") for n in active_t2}
|
||||
active_start, active_end = await asyncio.gather(
|
||||
asyncio.to_thread(session.get_active_nodes, at_time=start),
|
||||
asyncio.to_thread(session.get_active_nodes, at_time=end),
|
||||
)
|
||||
|
||||
start_ids = {node.get("id") for node in active_start}
|
||||
end_ids = {node.get("id") for node in active_end}
|
||||
return TemporalDiffResponse(
|
||||
from_time=t1.isoformat(),
|
||||
to_time=t2.isoformat(),
|
||||
added_nodes=sorted(ids_t2 - ids_t1),
|
||||
removed_nodes=sorted(ids_t1 - ids_t2),
|
||||
from_time=start.isoformat(),
|
||||
to_time=end.isoformat(),
|
||||
added_nodes=sorted(end_ids - start_ids),
|
||||
removed_nodes=sorted(start_ids - end_ids),
|
||||
)
|
||||
|
||||
|
||||
@@ -93,38 +98,25 @@ async def temporal_diff(
|
||||
async def temporal_patterns(
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Detect temporal patterns (trends, cycles, anomalies).
|
||||
|
||||
Falls back to a stub when ``TemporalPatternDetector`` is not available.
|
||||
"""
|
||||
try:
|
||||
from ...kg import TemporalPatternDetector
|
||||
|
||||
detector = TemporalPatternDetector()
|
||||
|
||||
|
||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
graph_dict = {
|
||||
"entities": [
|
||||
{"id": n.get("id"), "type": n.get("type"), "metadata": n.get("metadata", {})}
|
||||
for n in nodes
|
||||
],
|
||||
"relationships": [
|
||||
{"source": e.get("source"), "target": e.get("target"),
|
||||
"type": e.get("type"), "metadata": e.get("metadata", {})}
|
||||
for e in edges
|
||||
],
|
||||
}
|
||||
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
patterns = await asyncio.to_thread(detector.detect_patterns, graph_dict)
|
||||
if isinstance(patterns, dict):
|
||||
patterns = patterns.get("patterns", [])
|
||||
return TemporalPatternResponse(patterns=patterns if isinstance(patterns, list) else [])
|
||||
except ImportError:
|
||||
# TemporalPatternDetector is an optional KG extra; return empty gracefully.
|
||||
return TemporalPatternResponse(patterns=[])
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("temporal_patterns failed: %s", exc, exc_info=True)
|
||||
logger.warning("temporal_patterns failed: %s", exc, exc_info=True)
|
||||
return TemporalPatternResponse(patterns=[])
|
||||
|
||||
|
||||
@router.get("/bounds", response_model=TemporalBoundsResponse)
|
||||
async def temporal_bounds(
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
bounds = await asyncio.to_thread(session.get_temporal_bounds)
|
||||
return TemporalBoundsResponse(**bounds)
|
||||
|
||||
@@ -1,141 +1,236 @@
|
||||
"""
|
||||
Vocabulary routes - SKOS ingestion, scheme listing, and hierarchy trees.
|
||||
"""
|
||||
Vocabulary routes for SKOS scheme discovery, hierarchy browsing, and import.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import ConceptNode, VocabularyScheme
|
||||
from ..schemas import ConceptNode, ConceptSummary, VocabularyImportResponse, VocabularyScheme
|
||||
from ..session import GraphSession
|
||||
from ..utils.rdf_parser import parse_skos_file
|
||||
|
||||
router = APIRouter(prefix="/api/vocabulary", tags=["Vocabulary"])
|
||||
|
||||
_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_ALLOWED_EXTENSIONS = frozenset({".ttl", ".rdf", ".owl", ".xml", ".jsonld", ".json-ld", ".json"})
|
||||
|
||||
|
||||
def _concept_summary(node: dict, scheme_uri: Optional[str] = None, parent_uri: Optional[str] = None) -> ConceptSummary:
|
||||
properties = node.get("properties", {})
|
||||
alt_labels = properties.get("alt_labels") or []
|
||||
if isinstance(alt_labels, str):
|
||||
alt_labels = [alt_labels]
|
||||
return ConceptSummary(
|
||||
uri=node.get("id", ""),
|
||||
pref_label=properties.get("pref_label") or properties.get("content", node.get("content", node.get("id", ""))),
|
||||
alt_labels=list(alt_labels),
|
||||
description=properties.get("description"),
|
||||
notation=properties.get("notation"),
|
||||
scheme_uri=scheme_uri,
|
||||
parent_uri=parent_uri,
|
||||
)
|
||||
|
||||
|
||||
def _collect_scheme_members(edges: List[dict], scheme_uri: str) -> set[str]:
|
||||
members: set[str] = set()
|
||||
for edge in edges:
|
||||
source = edge.get("source")
|
||||
target = edge.get("target")
|
||||
edge_type = edge.get("type")
|
||||
if target == scheme_uri and edge_type in {"skos:inScheme", "skos:topConceptOf"}:
|
||||
members.add(source)
|
||||
elif source == scheme_uri and edge_type == "skos:hasTopConcept":
|
||||
members.add(target)
|
||||
return members
|
||||
|
||||
|
||||
def _collect_parent_map(member_ids: set[str], edges: List[dict]) -> Dict[str, str]:
|
||||
parent_by_child: Dict[str, str] = {}
|
||||
for edge in edges:
|
||||
source = edge.get("source")
|
||||
target = edge.get("target")
|
||||
edge_type = edge.get("type")
|
||||
if source not in member_ids or target not in member_ids:
|
||||
continue
|
||||
if edge_type == "skos:broader":
|
||||
parent_by_child.setdefault(source, target)
|
||||
elif edge_type == "skos:narrower":
|
||||
parent_by_child.setdefault(target, source)
|
||||
return parent_by_child
|
||||
|
||||
|
||||
def _build_hierarchy(concepts: Dict[str, ConceptSummary], parent_by_child: Dict[str, str]) -> List[ConceptNode]:
|
||||
children_by_parent: Dict[str, List[str]] = defaultdict(list)
|
||||
for child_uri, parent_uri in parent_by_child.items():
|
||||
if child_uri != parent_uri:
|
||||
children_by_parent[parent_uri].append(child_uri)
|
||||
|
||||
def attach(uri: str, trail: set[str]) -> ConceptNode:
|
||||
concept = concepts[uri]
|
||||
child_nodes = [
|
||||
attach(child_uri, trail | {uri})
|
||||
for child_uri in sorted(children_by_parent.get(uri, []))
|
||||
if child_uri not in trail
|
||||
]
|
||||
return ConceptNode(
|
||||
uri=concept.uri,
|
||||
pref_label=concept.pref_label,
|
||||
alt_labels=concept.alt_labels,
|
||||
description=concept.description,
|
||||
notation=concept.notation,
|
||||
scheme_uri=concept.scheme_uri,
|
||||
parent_uri=concept.parent_uri,
|
||||
children=child_nodes or None,
|
||||
)
|
||||
|
||||
root_ids = [uri for uri in sorted(concepts.keys()) if uri not in parent_by_child]
|
||||
if not root_ids:
|
||||
root_ids = sorted(concepts.keys())
|
||||
return [attach(uri, {uri}) for uri in root_ids]
|
||||
|
||||
|
||||
@router.get("/schemes", response_model=List[VocabularyScheme])
|
||||
async def list_schemes(
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""List all available SKOS Concept Schemes (Vocabularies)."""
|
||||
nodes, _ = await asyncio.to_thread(
|
||||
session.get_nodes, node_type="skos:ConceptScheme", skip=0, limit=999_999
|
||||
session.get_nodes,
|
||||
node_type="skos:ConceptScheme",
|
||||
skip=0,
|
||||
limit=999_999,
|
||||
)
|
||||
|
||||
schemes = []
|
||||
for n in nodes:
|
||||
meta = n.get("metadata", n.get("properties", {}))
|
||||
schemes.append(
|
||||
VocabularyScheme(
|
||||
uri=n.get("id", ""),
|
||||
label=meta.get("content", n.get("content", n.get("id", ""))),
|
||||
description=meta.get("description"),
|
||||
)
|
||||
return [
|
||||
VocabularyScheme(
|
||||
uri=node.get("id", ""),
|
||||
label=node.get("properties", {}).get("content", node.get("content", node.get("id", ""))),
|
||||
description=node.get("properties", {}).get("description"),
|
||||
)
|
||||
return schemes
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_vocabulary(
|
||||
file: UploadFile = File(...),
|
||||
@router.get("/concepts", response_model=List[ConceptSummary])
|
||||
async def list_concepts(
|
||||
scheme: str = Query(..., description="ConceptScheme URI"),
|
||||
search: Optional[str] = Query(None, description="Filter concepts by label or metadata"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Import a SKOS vocabulary from a .ttl or .rdf file.
|
||||
"""
|
||||
content = await file.read()
|
||||
filename = file.filename or "vocabulary.ttl"
|
||||
|
||||
nodes, _ = await asyncio.to_thread(
|
||||
session.get_nodes,
|
||||
node_type="skos:Concept",
|
||||
search=search,
|
||||
skip=0,
|
||||
limit=999_999,
|
||||
)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
|
||||
parse_format = "xml" if filename.endswith((".rdf", ".owl")) else "turtle"
|
||||
|
||||
try:
|
||||
nodes, edges = await asyncio.to_thread(parse_skos_file, content, parse_format)
|
||||
except ValueError as exc:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
member_ids = _collect_scheme_members(edges, scheme)
|
||||
parent_by_child = _collect_parent_map(member_ids, edges)
|
||||
|
||||
added_nodes = await asyncio.to_thread(session.add_nodes, nodes)
|
||||
added_edges = await asyncio.to_thread(session.add_edges, edges)
|
||||
concepts = []
|
||||
for node in nodes:
|
||||
node_id = node.get("id")
|
||||
if node_id not in member_ids:
|
||||
continue
|
||||
concepts.append(_concept_summary(node, scheme_uri=scheme, parent_uri=parent_by_child.get(node_id)))
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"nodes_added": added_nodes,
|
||||
"edges_added": added_edges,
|
||||
}
|
||||
return sorted(concepts, key=lambda concept: (concept.pref_label.lower(), concept.uri))
|
||||
|
||||
|
||||
@router.get("/hierarchy", response_model=List[ConceptNode])
|
||||
async def get_hierarchy(
|
||||
scheme: str = Query(..., description="The URI of the ConceptScheme to load"),
|
||||
scheme: str = Query(..., description="ConceptScheme URI"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Fetch the nested broader/narrower tree for a specific vocabulary scheme.
|
||||
Executes in O(V+E) time by building the adjacency list in memory.
|
||||
"""
|
||||
|
||||
nodes, _ = await asyncio.to_thread(
|
||||
session.get_nodes, node_type="skos:Concept", skip=0, limit=999_999
|
||||
session.get_nodes,
|
||||
node_type="skos:Concept",
|
||||
skip=0,
|
||||
limit=999_999,
|
||||
)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
|
||||
|
||||
scheme_node_ids = set()
|
||||
for e in edges:
|
||||
src, tgt, etype = e.get("source"), e.get("target"), e.get("type")
|
||||
if tgt == scheme and etype in ("skos:inScheme", "skos:topConceptOf"):
|
||||
scheme_node_ids.add(src)
|
||||
elif src == scheme and etype == "skos:hasTopConcept":
|
||||
scheme_node_ids.add(tgt)
|
||||
member_ids = _collect_scheme_members(edges, scheme)
|
||||
parent_by_child = _collect_parent_map(member_ids, edges)
|
||||
|
||||
node_map = {}
|
||||
for n in nodes:
|
||||
nid = n.get("id")
|
||||
if nid in scheme_node_ids:
|
||||
meta = n.get("metadata", n.get("properties", {}))
|
||||
node_map[nid] = ConceptNode(
|
||||
uri=nid,
|
||||
pref_label=meta.get("content", n.get("content", nid)),
|
||||
alt_labels=meta.get("alt_labels", []),
|
||||
children=[]
|
||||
concepts: Dict[str, ConceptSummary] = {}
|
||||
for node in nodes:
|
||||
node_id = node.get("id")
|
||||
if node_id not in member_ids:
|
||||
continue
|
||||
concepts[node_id] = _concept_summary(
|
||||
node,
|
||||
scheme_uri=scheme,
|
||||
parent_uri=parent_by_child.get(node_id),
|
||||
)
|
||||
|
||||
return _build_hierarchy(concepts, parent_by_child)
|
||||
|
||||
|
||||
@router.post("/import", response_model=VocabularyImportResponse)
|
||||
async def import_vocabulary(
|
||||
file: Optional[UploadFile] = File(None),
|
||||
text: Optional[str] = Form(None),
|
||||
format: Optional[str] = Form(None),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
if file is None and not text:
|
||||
raise HTTPException(status_code=422, detail="Provide either a vocabulary file or raw RDF text.")
|
||||
|
||||
filename = file.filename if file else None
|
||||
if file is not None:
|
||||
if filename:
|
||||
import os as _os
|
||||
ext = _os.path.splitext(filename.lower())[1]
|
||||
if ext not in _ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unsupported file type '{ext}'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}",
|
||||
)
|
||||
content = await file.read()
|
||||
if len(content) > _MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Upload exceeds the {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit.",
|
||||
)
|
||||
else:
|
||||
encoded = text.encode("utf-8")
|
||||
if len(encoded) > _MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Text payload exceeds the {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit.",
|
||||
)
|
||||
content = encoded
|
||||
|
||||
|
||||
parent_to_children = defaultdict(list)
|
||||
has_parent = set()
|
||||
|
||||
for e in edges:
|
||||
src, tgt, etype = e.get("source"), e.get("target"), e.get("type")
|
||||
if src in node_map and tgt in node_map:
|
||||
if etype == "skos:broader":
|
||||
# Source is narrower (child), Target is broader (parent)
|
||||
parent_to_children[tgt].append(src)
|
||||
has_parent.add(src)
|
||||
elif etype == "skos:narrower":
|
||||
# Source is broader (parent), Target is narrower (child)
|
||||
parent_to_children[src].append(tgt)
|
||||
has_parent.add(tgt)
|
||||
|
||||
# Assemble nested tree — cycle-safe via visited set.
|
||||
def _attach_children(nid: str, visited: set) -> ConceptNode:
|
||||
node_obj = node_map[nid]
|
||||
child_ids = [c for c in parent_to_children.get(nid, []) if c not in visited]
|
||||
if child_ids:
|
||||
node_obj.children = [
|
||||
_attach_children(cid, visited | {nid}) for cid in child_ids
|
||||
]
|
||||
parse_format = (format or "").strip().lower() or None
|
||||
if parse_format is None:
|
||||
if filename:
|
||||
lower_name = filename.lower()
|
||||
if lower_name.endswith((".rdf", ".owl", ".xml")):
|
||||
parse_format = "xml"
|
||||
elif lower_name.endswith((".jsonld", ".json-ld", ".json")):
|
||||
parse_format = "json-ld"
|
||||
else:
|
||||
parse_format = "turtle"
|
||||
else:
|
||||
node_obj.children = None # leaf node signal for the UI
|
||||
return node_obj
|
||||
parse_format = "turtle"
|
||||
|
||||
roots = [
|
||||
_attach_children(nid, {nid})
|
||||
for nid in node_map
|
||||
if nid not in has_parent
|
||||
]
|
||||
return roots
|
||||
try:
|
||||
nodes, edges = await asyncio.to_thread(parse_skos_file, content, parse_format)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
|
||||
edges_added = await asyncio.to_thread(session.add_edges, edges)
|
||||
|
||||
return VocabularyImportResponse(
|
||||
status="success",
|
||||
filename=filename,
|
||||
nodes_added=nodes_added,
|
||||
edges_added=edges_added,
|
||||
format=parse_format,
|
||||
)
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
"""
|
||||
Semantica Explorer : Pydantic Schemas
|
||||
|
||||
All request/response models for the Knowledge Explorer REST API.
|
||||
Shared Pydantic schemas for the Semantica Knowledge Explorer API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error envelope."""
|
||||
detail: str
|
||||
status_code: int = 500
|
||||
|
||||
|
||||
|
||||
class NodeResponse(BaseModel):
|
||||
"""Single node representation."""
|
||||
id: str
|
||||
type: str
|
||||
content: str = ""
|
||||
@@ -29,32 +22,36 @@ class NodeResponse(BaseModel):
|
||||
|
||||
|
||||
class EdgeResponse(BaseModel):
|
||||
"""Single edge representation."""
|
||||
id: str
|
||||
familyId: str
|
||||
source: str
|
||||
target: str
|
||||
type: str
|
||||
weight: float = 1.0
|
||||
properties: Dict[str, Any] = Field(default_factory=dict)
|
||||
valid_from: Optional[str] = None
|
||||
valid_until: Optional[str] = None
|
||||
|
||||
|
||||
class NodeListResponse(BaseModel):
|
||||
"""Paginated node list."""
|
||||
nodes: List[NodeResponse]
|
||||
total: int
|
||||
skip: int = 0
|
||||
limit: int = 100
|
||||
next_cursor: Optional[str] = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class EdgeListResponse(BaseModel):
|
||||
"""Paginated edge list."""
|
||||
edges: List[EdgeResponse]
|
||||
total: int
|
||||
skip: int = 0
|
||||
limit: int = 100
|
||||
next_cursor: Optional[str] = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class NeighborResponse(BaseModel):
|
||||
"""Neighbor node with relationship info."""
|
||||
id: str
|
||||
type: str
|
||||
content: str = ""
|
||||
@@ -64,16 +61,15 @@ class NeighborResponse(BaseModel):
|
||||
|
||||
|
||||
class PathResponse(BaseModel):
|
||||
"""Path between two nodes."""
|
||||
source: str
|
||||
target: str
|
||||
algorithm: str
|
||||
path: List[str]
|
||||
edge_ids: List[str] = Field(default_factory=list)
|
||||
total_weight: float = 0.0
|
||||
|
||||
|
||||
class GraphStatsResponse(BaseModel):
|
||||
"""Graph-level statistics."""
|
||||
node_count: int
|
||||
edge_count: int
|
||||
node_types: Dict[str, int] = Field(default_factory=dict)
|
||||
@@ -82,37 +78,30 @@ class GraphStatsResponse(BaseModel):
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
"""Search request body."""
|
||||
query: str
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
limit: int = 20
|
||||
filters: Dict[str, Any] = Field(default_factory=dict)
|
||||
limit: int = Field(default=20, ge=1, le=200)
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
"""Single search result."""
|
||||
node: NodeResponse
|
||||
score: float = 0.0
|
||||
|
||||
|
||||
class SearchResultResponse(BaseModel):
|
||||
"""Search results."""
|
||||
results: List[SearchResultItem]
|
||||
total: int
|
||||
query: str
|
||||
|
||||
|
||||
|
||||
|
||||
class AnalyticsResponse(BaseModel):
|
||||
"""Analytics results."""
|
||||
centrality: Optional[Dict[str, Any]] = None
|
||||
community: Optional[Dict[str, Any]] = None
|
||||
connectivity: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ValidationIssue(BaseModel):
|
||||
"""Single validation error or warning."""
|
||||
severity: str # "error" or "warning"
|
||||
severity: str
|
||||
message: str
|
||||
node_id: Optional[str] = None
|
||||
edge_source: Optional[str] = None
|
||||
@@ -120,16 +109,13 @@ class ValidationIssue(BaseModel):
|
||||
|
||||
|
||||
class ValidationReportResponse(BaseModel):
|
||||
"""Graph validation report."""
|
||||
valid: bool
|
||||
error_count: int = 0
|
||||
warning_count: int = 0
|
||||
issues: List[ValidationIssue] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
class DecisionResponse(BaseModel):
|
||||
"""Single decision."""
|
||||
decision_id: str
|
||||
category: str = ""
|
||||
scenario: str = ""
|
||||
@@ -141,28 +127,23 @@ class DecisionResponse(BaseModel):
|
||||
|
||||
|
||||
class CausalChainResponse(BaseModel):
|
||||
"""Causal chain for a decision."""
|
||||
decision_id: str
|
||||
chain: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ComplianceResponse(BaseModel):
|
||||
"""Policy compliance check result."""
|
||||
decision_id: str
|
||||
compliant: bool = True
|
||||
violations: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
class TemporalSnapshotResponse(BaseModel):
|
||||
"""Graph state at a point in time."""
|
||||
timestamp: str
|
||||
active_nodes: List[NodeResponse]
|
||||
active_node_count: int
|
||||
|
||||
|
||||
class TemporalDiffResponse(BaseModel):
|
||||
"""Diff between two temporal snapshots."""
|
||||
from_time: str
|
||||
to_time: str
|
||||
added_nodes: List[str] = Field(default_factory=list)
|
||||
@@ -170,85 +151,88 @@ class TemporalDiffResponse(BaseModel):
|
||||
|
||||
|
||||
class TemporalPatternResponse(BaseModel):
|
||||
"""Detected temporal patterns."""
|
||||
patterns: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EnrichExtractRequest(BaseModel):
|
||||
"""Entity/relation extraction from text."""
|
||||
text: str
|
||||
|
||||
|
||||
class EnrichExtractResponse(BaseModel):
|
||||
"""Extraction results."""
|
||||
entities: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
relations: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LinkPredictionRequest(BaseModel):
|
||||
"""Link prediction request."""
|
||||
node_id: str
|
||||
top_n: int = 10
|
||||
top_n: int = Field(default=10, ge=1, le=200)
|
||||
candidate_type: Optional[str] = None
|
||||
min_score: float = Field(default=0.0, ge=0.0)
|
||||
|
||||
|
||||
class LinkPredictionResponse(BaseModel):
|
||||
"""Link prediction results."""
|
||||
node_id: str
|
||||
predictions: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DedupRequest(BaseModel):
|
||||
"""Deduplication scan request."""
|
||||
threshold: float = 0.8
|
||||
threshold: float = Field(default=0.8, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class DedupResponse(BaseModel):
|
||||
"""Deduplication results."""
|
||||
duplicates: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
total_flagged: int = 0
|
||||
|
||||
|
||||
class ReasoningRequest(BaseModel):
|
||||
"""Reasoning request."""
|
||||
facts: List[str]
|
||||
rules: List[str]
|
||||
mode: str = "forward" # forward, backward, rete
|
||||
mode: str = "forward"
|
||||
apply_to_graph: bool = False
|
||||
inferred_edge_type: Optional[str] = None
|
||||
|
||||
|
||||
class ReasoningResponse(BaseModel):
|
||||
"""Reasoning results."""
|
||||
inferred_facts: List[str] = Field(default_factory=list)
|
||||
rules_fired: int = 0
|
||||
|
||||
|
||||
added_edges: int = 0
|
||||
mutated: bool = False
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
"""Export request."""
|
||||
format: str = "json"
|
||||
format: str = "json"
|
||||
node_ids: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ExportResponse(BaseModel):
|
||||
"""Export result metadata."""
|
||||
format: str
|
||||
content_type: str
|
||||
filename: str
|
||||
size_bytes: int = 0
|
||||
|
||||
|
||||
class ImportResponse(BaseModel):
|
||||
status: str = "success"
|
||||
message: str = "Import successful"
|
||||
nodes_added: int = 0
|
||||
edges_added: int = 0
|
||||
nodes_imported: Optional[int] = None
|
||||
edges_imported: Optional[int] = None
|
||||
|
||||
|
||||
class StandardMessageResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
class AnnotationCreate(BaseModel):
|
||||
"""Create an annotation."""
|
||||
node_id: str
|
||||
content: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
visibility: str = "public"
|
||||
visibility: str = "public"
|
||||
|
||||
|
||||
class AnnotationResponse(BaseModel):
|
||||
"""Single annotation."""
|
||||
annotation_id: str
|
||||
node_id: str
|
||||
content: str
|
||||
@@ -256,19 +240,48 @@ class AnnotationResponse(BaseModel):
|
||||
visibility: str = "public"
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
class VocabularyScheme(BaseModel):
|
||||
""" A SKOS Concept Scheme (Vocabulary / Ontology)."""
|
||||
|
||||
uri: str
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class ConceptNode(BaseModel):
|
||||
""" A SKOS Concept, nested hierarchically."""
|
||||
|
||||
|
||||
|
||||
class ConceptSummary(BaseModel):
|
||||
uri: str
|
||||
pref_label: str
|
||||
alt_labels: List[str] = Field(default_factory=list)
|
||||
children: Optional[List['ConceptNode']] = None
|
||||
|
||||
|
||||
description: Optional[str] = None
|
||||
notation: Optional[str] = None
|
||||
scheme_uri: Optional[str] = None
|
||||
parent_uri: Optional[str] = None
|
||||
|
||||
|
||||
class ConceptNode(BaseModel):
|
||||
uri: str
|
||||
pref_label: str
|
||||
alt_labels: List[str] = Field(default_factory=list)
|
||||
description: Optional[str] = None
|
||||
notation: Optional[str] = None
|
||||
scheme_uri: Optional[str] = None
|
||||
parent_uri: Optional[str] = None
|
||||
children: Optional[List["ConceptNode"]] = None
|
||||
|
||||
|
||||
class VocabularyImportResponse(BaseModel):
|
||||
status: str = "success"
|
||||
filename: Optional[str] = None
|
||||
nodes_added: int = 0
|
||||
edges_added: int = 0
|
||||
format: str
|
||||
|
||||
|
||||
class MergeRequest(BaseModel):
|
||||
primary_id: str
|
||||
duplicate_ids: List[str]
|
||||
|
||||
|
||||
class MergeResponse(BaseModel):
|
||||
merged_into: str
|
||||
removed_ids: List[str]
|
||||
edges_updated: int
|
||||
|
||||
+414
-129
@@ -1,17 +1,15 @@
|
||||
"""
|
||||
Semantica Explorer : Graph Session
|
||||
|
||||
Holds a loaded ContextGraph together with lazily-initialized analytics
|
||||
components. One session is created at server startup and shared across
|
||||
all API requests via FastAPI's dependency injection.
|
||||
"""
|
||||
Semantica Explorer session helpers.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime, UTC
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from ..context.context_graph import ContextGraph
|
||||
from ..context.context_graph import ContextGraph, _resolve_edge_identity
|
||||
|
||||
_KG_AVAILABLE = False
|
||||
try:
|
||||
@@ -25,20 +23,14 @@ try:
|
||||
PathFinder,
|
||||
SimilarityCalculator,
|
||||
)
|
||||
|
||||
_KG_AVAILABLE = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class GraphSession:
|
||||
"""
|
||||
Holds a loaded graph and its associated analytics components.
|
||||
|
||||
Thread safety: all mutations to the graph or the annotations store
|
||||
must go through methods on this class, which are protected by an
|
||||
``RLock``. Lazy analytics properties are also initialised under the
|
||||
same lock to prevent double-instantiation under concurrent requests.
|
||||
"""
|
||||
"""Thread-safe session wrapper around a loaded ``ContextGraph``."""
|
||||
|
||||
def __init__(self, graph: ContextGraph) -> None:
|
||||
self.graph = graph
|
||||
@@ -57,14 +49,67 @@ class GraphSession:
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str) -> "GraphSession":
|
||||
"""Load a ContextGraph from a JSON file and wrap it in a session."""
|
||||
graph = ContextGraph()
|
||||
graph.load_from_file(path)
|
||||
return cls(graph)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lazy analytics properties (thread-safe double-checked locking)
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _encode_cursor(value: str) -> str:
|
||||
return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _decode_cursor(cursor: Optional[str]) -> Optional[str]:
|
||||
if not cursor:
|
||||
return None
|
||||
try:
|
||||
return base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _coerce_float(value: Any) -> Optional[float]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _apply_cursor(items: List[str], start_after: Optional[str]) -> int:
|
||||
if not start_after:
|
||||
return 0
|
||||
for index, item in enumerate(items):
|
||||
if item == start_after:
|
||||
return index + 1
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _node_matches_search(node: Dict[str, Any], search: Optional[str]) -> bool:
|
||||
if not search:
|
||||
return True
|
||||
query = search.lower().strip()
|
||||
haystacks = [
|
||||
str(node.get("id", "")),
|
||||
str(node.get("type", "")),
|
||||
str(node.get("content", "")),
|
||||
json.dumps(node.get("properties", {}), default=str),
|
||||
]
|
||||
return any(query in haystack.lower() for haystack in haystacks)
|
||||
|
||||
def _node_matches_bbox(
|
||||
self,
|
||||
node: Dict[str, Any],
|
||||
bbox: Optional[tuple[float, float, float, float]],
|
||||
) -> bool:
|
||||
if bbox is None:
|
||||
return True
|
||||
x = self._coerce_float(node.get("properties", {}).get("x"))
|
||||
y = self._coerce_float(node.get("properties", {}).get("y"))
|
||||
if x is None or y is None:
|
||||
return False
|
||||
min_x, min_y, max_x, max_y = bbox
|
||||
return min_x <= x <= max_x and min_y <= y <= max_y
|
||||
|
||||
@property
|
||||
def centrality(self) -> Any:
|
||||
@@ -122,14 +167,148 @@ class GraphSession:
|
||||
self._validator = GraphValidator()
|
||||
return self._validator
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph read helpers
|
||||
# ------------------------------------------------------------------
|
||||
def normalize_node(self, node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
meta: Dict[str, Any] = {}
|
||||
meta.update(node.get("metadata", {}) or {})
|
||||
meta.update(node.get("properties", {}) or {})
|
||||
|
||||
content = node.get("content")
|
||||
if content is None:
|
||||
content = (
|
||||
meta.get("content")
|
||||
or meta.get("text")
|
||||
or meta.get("label")
|
||||
or meta.get("name")
|
||||
or node.get("label")
|
||||
or node.get("name")
|
||||
or node.get("id", "")
|
||||
)
|
||||
|
||||
valid_from = node.get("valid_from", meta.get("valid_from"))
|
||||
valid_until = node.get("valid_until", meta.get("valid_until"))
|
||||
|
||||
properties = dict(meta)
|
||||
properties.setdefault("content", content)
|
||||
if valid_from is not None:
|
||||
properties["valid_from"] = valid_from
|
||||
if valid_until is not None:
|
||||
properties["valid_until"] = valid_until
|
||||
|
||||
return {
|
||||
"id": str(node.get("id", "")),
|
||||
"type": str(node.get("type", "entity")),
|
||||
"content": str(content or ""),
|
||||
"properties": properties,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
}
|
||||
|
||||
def normalize_edge(self, edge: Dict[str, Any]) -> Dict[str, Any]:
|
||||
meta: Dict[str, Any] = {}
|
||||
meta.update(edge.get("metadata", {}) or {})
|
||||
meta.update(edge.get("properties", {}) or {})
|
||||
|
||||
valid_from = edge.get("valid_from", meta.get("valid_from"))
|
||||
valid_until = edge.get("valid_until", meta.get("valid_until"))
|
||||
weight = self._coerce_float(edge.get("weight"))
|
||||
if weight is None:
|
||||
weight = 1.0
|
||||
|
||||
properties = dict(meta)
|
||||
if valid_from is not None:
|
||||
properties["valid_from"] = valid_from
|
||||
if valid_until is not None:
|
||||
properties["valid_until"] = valid_until
|
||||
|
||||
edge_id, family_id = _resolve_edge_identity(
|
||||
source_id=str(edge.get("source", edge.get("source_id", ""))),
|
||||
target_id=str(edge.get("target", edge.get("target_id", ""))),
|
||||
edge_type=str(edge.get("type", "related_to")),
|
||||
weight=weight,
|
||||
metadata=properties,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
edge_id=(
|
||||
edge.get("id")
|
||||
or edge.get("edge_id")
|
||||
or meta.get("id")
|
||||
or meta.get("edge_id")
|
||||
),
|
||||
family_id=(
|
||||
edge.get("familyId")
|
||||
or edge.get("family_id")
|
||||
or meta.get("familyId")
|
||||
or meta.get("family_id")
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"id": edge_id,
|
||||
"familyId": family_id,
|
||||
"source": str(edge.get("source", edge.get("source_id", ""))),
|
||||
"target": str(edge.get("target", edge.get("target_id", ""))),
|
||||
"type": str(edge.get("type", "related_to")),
|
||||
"weight": weight,
|
||||
"properties": properties,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
}
|
||||
|
||||
def get_node(self, node_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a single node by ID, or ``None``."""
|
||||
with self._lock:
|
||||
return self.graph.find_node(node_id)
|
||||
node = self.graph.find_node(node_id)
|
||||
if node is None:
|
||||
return None
|
||||
return self.normalize_node(node)
|
||||
|
||||
def paginate_nodes(
|
||||
self,
|
||||
node_type: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
cursor: Optional[str] = None,
|
||||
bbox: Optional[tuple[float, float, float, float]] = None,
|
||||
) -> tuple[list[dict[str, Any]], int, Optional[str]]:
|
||||
with self._lock:
|
||||
node_ids: Iterable[str]
|
||||
if node_type:
|
||||
node_ids = sorted(
|
||||
(node_id for node_id in self.graph.node_type_index.get(node_type, set()) if node_id is not None),
|
||||
key=lambda value: str(value),
|
||||
)
|
||||
else:
|
||||
node_ids = sorted(
|
||||
(node_id for node_id in self.graph.nodes.keys() if node_id is not None),
|
||||
key=lambda value: str(value),
|
||||
)
|
||||
|
||||
filtered_ids: List[str] = []
|
||||
normalized_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
for node_id in node_ids:
|
||||
raw = self.graph.find_node(node_id)
|
||||
if raw is None:
|
||||
continue
|
||||
normalized = self.normalize_node(raw)
|
||||
if not self._node_matches_search(normalized, search):
|
||||
continue
|
||||
if not self._node_matches_bbox(normalized, bbox):
|
||||
continue
|
||||
filtered_ids.append(node_id)
|
||||
normalized_by_id[node_id] = normalized
|
||||
|
||||
total = len(filtered_ids)
|
||||
start_index = skip
|
||||
decoded_cursor = self._decode_cursor(cursor)
|
||||
if decoded_cursor:
|
||||
start_index = self._apply_cursor(filtered_ids, decoded_cursor)
|
||||
|
||||
page_ids = filtered_ids[start_index : start_index + limit]
|
||||
next_cursor = None
|
||||
if start_index + limit < total and page_ids:
|
||||
next_cursor = self._encode_cursor(page_ids[-1])
|
||||
|
||||
return [normalized_by_id[node_id] for node_id in page_ids], total, next_cursor
|
||||
|
||||
def get_nodes(
|
||||
self,
|
||||
@@ -138,31 +317,57 @@ class GraphSession:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Return a paginated slice of nodes and the total count."""
|
||||
page, total, _ = self.paginate_nodes(
|
||||
node_type=node_type,
|
||||
search=search,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return page, total
|
||||
|
||||
def paginate_edges(
|
||||
self,
|
||||
edge_type: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
target: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
cursor: Optional[str] = None,
|
||||
) -> tuple[list[dict[str, Any]], int, Optional[str]]:
|
||||
with self._lock:
|
||||
if search:
|
||||
# Must load all nodes to apply the in-memory keyword filter.
|
||||
all_nodes = self.graph.find_nodes(node_type=node_type)
|
||||
search_lower = search.lower()
|
||||
all_nodes = [
|
||||
n for n in all_nodes
|
||||
if search_lower in n.get("id", "").lower()
|
||||
or search_lower in n.get("content", "").lower()
|
||||
or search_lower in str(n.get("metadata", {})).lower()
|
||||
]
|
||||
total = len(all_nodes)
|
||||
page = all_nodes[skip: skip + limit]
|
||||
else:
|
||||
# Delegate pagination to the graph layer to avoid loading
|
||||
# the full node list into memory unnecessarily.
|
||||
stats = self.graph.stats()
|
||||
total = (
|
||||
stats.get("node_types", {}).get(node_type, 0)
|
||||
if node_type
|
||||
else stats.get("node_count", 0)
|
||||
)
|
||||
page = self.graph.find_nodes(node_type=node_type, skip=skip, limit=limit)
|
||||
return page, total
|
||||
raw_edges = self.graph.find_edges(edge_type=edge_type)
|
||||
|
||||
normalized_edges: List[Dict[str, Any]] = []
|
||||
keys: List[str] = []
|
||||
for edge in raw_edges:
|
||||
normalized = self.normalize_edge(edge)
|
||||
if not normalized["source"] or not normalized["target"]:
|
||||
continue
|
||||
if source and normalized["source"] != source:
|
||||
continue
|
||||
if target and normalized["target"] != target:
|
||||
continue
|
||||
edge_key = str(normalized["id"])
|
||||
normalized_edges.append(normalized)
|
||||
keys.append(edge_key)
|
||||
|
||||
ordered = sorted(zip(keys, normalized_edges), key=lambda item: item[0])
|
||||
ordered_keys = [key for key, _ in ordered]
|
||||
ordered_edges = [edge for _, edge in ordered]
|
||||
|
||||
total = len(ordered_edges)
|
||||
start_index = skip
|
||||
decoded_cursor = self._decode_cursor(cursor)
|
||||
if decoded_cursor:
|
||||
start_index = self._apply_cursor(ordered_keys, decoded_cursor)
|
||||
|
||||
page_edges = ordered_edges[start_index : start_index + limit]
|
||||
next_cursor = None
|
||||
if start_index + limit < total and page_edges:
|
||||
last = page_edges[-1]
|
||||
next_cursor = self._encode_cursor(str(last["id"]))
|
||||
|
||||
return page_edges, total, next_cursor
|
||||
|
||||
def get_edges(
|
||||
self,
|
||||
@@ -172,145 +377,225 @@ class GraphSession:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Return a paginated slice of edges and the total count."""
|
||||
with self._lock:
|
||||
if source or target:
|
||||
# Must load all edges to apply source/target filters in memory.
|
||||
all_edges = self.graph.find_edges(edge_type=edge_type)
|
||||
if source:
|
||||
all_edges = [e for e in all_edges if e.get("source") == source]
|
||||
if target:
|
||||
all_edges = [e for e in all_edges if e.get("target") == target]
|
||||
total = len(all_edges)
|
||||
page = all_edges[skip: skip + limit]
|
||||
else:
|
||||
stats = self.graph.stats()
|
||||
total = (
|
||||
stats.get("edge_types", {}).get(edge_type, 0)
|
||||
if edge_type
|
||||
else stats.get("edge_count", 0)
|
||||
)
|
||||
page = self.graph.find_edges(edge_type=edge_type, skip=skip, limit=limit)
|
||||
return page, total
|
||||
page, total, _ = self.paginate_edges(
|
||||
edge_type=edge_type,
|
||||
source=source,
|
||||
target=target,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return page, total
|
||||
|
||||
def get_neighbors(self, node_id: str, depth: int = 1) -> List[Dict[str, Any]]:
|
||||
"""Get neighbours for a node (BFS). Returns [] for unknown nodes."""
|
||||
with self._lock:
|
||||
return self.graph.get_neighbors(node_id, hops=depth)
|
||||
|
||||
def search(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Keyword search across node content.
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
filters = filters or {}
|
||||
try:
|
||||
with self._lock:
|
||||
raw = self.graph.query(query)[:limit]
|
||||
except Exception:
|
||||
raw = []
|
||||
|
||||
``ContextGraph.query`` returns ``{"node": node.to_dict(), "score": …}``
|
||||
where ``node.to_dict()`` uses a ``"properties"`` envelope. We normalise
|
||||
each result to the flat ``{"id", "type", "content", "metadata"}`` shape
|
||||
that the rest of the session/route layer expects.
|
||||
"""
|
||||
with self._lock:
|
||||
raw = self.graph.query(query)[:limit]
|
||||
if not raw:
|
||||
nodes, _ = self.get_nodes(search=query, skip=0, limit=max(limit * 5, limit))
|
||||
scored = []
|
||||
lowered_query = query.lower().strip()
|
||||
for node in nodes:
|
||||
haystacks = [
|
||||
str(node.get("id", "")),
|
||||
str(node.get("content", "")),
|
||||
json.dumps(node.get("properties", {}), default=str),
|
||||
]
|
||||
best_score = 0.0
|
||||
for haystack in haystacks:
|
||||
lowered = haystack.lower()
|
||||
if lowered == lowered_query:
|
||||
best_score = max(best_score, 1.0)
|
||||
elif lowered_query in lowered:
|
||||
best_score = max(best_score, min(0.9, len(lowered_query) / max(len(lowered), 1)))
|
||||
if best_score > 0:
|
||||
scored.append({"node": node, "score": round(best_score, 4)})
|
||||
raw = sorted(scored, key=lambda item: item["score"], reverse=True)[:limit]
|
||||
|
||||
normalised = []
|
||||
for r in raw:
|
||||
node = r.get("node", {})
|
||||
props = node.get("properties", {})
|
||||
flat_node = {
|
||||
"id": node.get("id", ""),
|
||||
"type": node.get("type", "entity"),
|
||||
"content": props.get("content", node.get("content", "")),
|
||||
"metadata": {k: v for k, v in props.items() if k != "content"},
|
||||
}
|
||||
normalised.append({"node": flat_node, "score": r.get("score", 0.0)})
|
||||
return normalised
|
||||
normalized = []
|
||||
for result in raw:
|
||||
result_node = result.get("node", {})
|
||||
node = (
|
||||
self.normalize_node(result_node)
|
||||
if "properties" in result_node or "metadata" in result_node or "content" in result_node
|
||||
else result_node
|
||||
)
|
||||
|
||||
filter_type = filters.get("type") or filters.get("node_type")
|
||||
if filter_type and node["type"] != filter_type:
|
||||
continue
|
||||
|
||||
min_confidence = self._coerce_float(filters.get("min_confidence"))
|
||||
node_confidence = self._coerce_float(node["properties"].get("confidence"))
|
||||
if min_confidence is not None and (
|
||||
node_confidence is None or node_confidence < min_confidence
|
||||
):
|
||||
continue
|
||||
|
||||
tags_filter = filters.get("tags")
|
||||
if tags_filter:
|
||||
node_tags = node["properties"].get("tags") or []
|
||||
if isinstance(node_tags, str):
|
||||
node_tags = [node_tags]
|
||||
if not set(tags_filter).issubset(set(node_tags)):
|
||||
continue
|
||||
|
||||
normalized.append({"node": node, "score": result.get("score", 0.0)})
|
||||
|
||||
return normalized[:limit]
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Graph-level statistics."""
|
||||
with self._lock:
|
||||
return self.graph.stats()
|
||||
|
||||
def get_active_nodes(
|
||||
self, at_time: Optional[datetime] = None, node_type: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Nodes active at a given point in time."""
|
||||
with self._lock:
|
||||
return self.graph.find_active_nodes(node_type=node_type, at_time=at_time)
|
||||
nodes = self.graph.find_active_nodes(node_type=node_type, at_time=at_time)
|
||||
return [self.normalize_node(node) for node in nodes]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Annotation CRUD
|
||||
# ------------------------------------------------------------------
|
||||
def get_temporal_bounds(self) -> Dict[str, Optional[str]]:
|
||||
min_valid_from: Optional[datetime] = None
|
||||
max_valid_until: Optional[datetime] = None
|
||||
|
||||
def _coerce_datetime(value: Any) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().replace("Z", "+00:00")
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(f"{text}-01-01")
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(UTC).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
with self._lock:
|
||||
raw_nodes = list(self.graph.nodes.values())
|
||||
|
||||
for raw_node in raw_nodes:
|
||||
if raw_node is None:
|
||||
continue
|
||||
node = self.normalize_node(self.graph.find_node(raw_node.node_id) or raw_node.to_dict())
|
||||
valid_from = _coerce_datetime(node.get("valid_from"))
|
||||
valid_until = _coerce_datetime(node.get("valid_until"))
|
||||
if valid_from is not None and (min_valid_from is None or valid_from < min_valid_from):
|
||||
min_valid_from = valid_from
|
||||
if valid_until is not None and (max_valid_until is None or valid_until > max_valid_until):
|
||||
max_valid_until = valid_until
|
||||
|
||||
return {
|
||||
"min": min_valid_from.isoformat() if min_valid_from is not None else None,
|
||||
"max": max_valid_until.isoformat() if max_valid_until is not None else None,
|
||||
}
|
||||
|
||||
def add_annotation(self, annotation: Dict[str, Any]) -> str:
|
||||
"""Add an annotation (mutates the dict in-place) and return its ID."""
|
||||
ann_id = str(uuid.uuid4())
|
||||
annotation["annotation_id"] = ann_id
|
||||
annotation["created_at"] = datetime.utcnow().isoformat()
|
||||
annotation["created_at"] = datetime.now(UTC).isoformat()
|
||||
with self._lock:
|
||||
self.annotations[ann_id] = annotation
|
||||
return ann_id
|
||||
|
||||
def get_annotation(self, annotation_id: str) -> Optional[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
return self.annotations.get(annotation_id)
|
||||
|
||||
def get_annotations(self, node_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""List annotations, optionally filtered by node_id."""
|
||||
with self._lock:
|
||||
anns = list(self.annotations.values())
|
||||
if node_id:
|
||||
anns = [a for a in anns if a.get("node_id") == node_id]
|
||||
anns = [ann for ann in anns if ann.get("node_id") == node_id]
|
||||
return anns
|
||||
|
||||
def delete_annotation(self, annotation_id: str) -> bool:
|
||||
"""Delete an annotation. Returns True if found and deleted."""
|
||||
with self._lock:
|
||||
return self.annotations.pop(annotation_id, None) is not None
|
||||
|
||||
def build_graph_dict(self, node_ids: Optional[list] = None) -> dict:
|
||||
"""
|
||||
Build the ``{entities, relationships}`` dict consumed by KG analytics
|
||||
helpers, exporters, and path-finders.
|
||||
|
||||
Args:
|
||||
node_ids: Optional list of node IDs to include. When given, only
|
||||
nodes in the list and edges between them are returned.
|
||||
"""
|
||||
nodes, _ = self.get_nodes(skip=0, limit=999_999)
|
||||
edges, _ = self.get_edges(skip=0, limit=999_999)
|
||||
|
||||
if node_ids:
|
||||
id_set = set(node_ids)
|
||||
nodes = [n for n in nodes if n.get("id") in id_set]
|
||||
nodes = [node for node in nodes if node.get("id") in id_set]
|
||||
edges = [
|
||||
e for e in edges
|
||||
if e.get("source") in id_set and e.get("target") in id_set
|
||||
edge
|
||||
for edge in edges
|
||||
if edge.get("source") in id_set and edge.get("target") in id_set
|
||||
]
|
||||
|
||||
return {
|
||||
"entities": [
|
||||
{
|
||||
"id": n.get("id"),
|
||||
"type": n.get("type", "entity"),
|
||||
"text": n.get("content", n.get("id", "")),
|
||||
"metadata": n.get("metadata", {}),
|
||||
"id": node.get("id"),
|
||||
"type": node.get("type", "entity"),
|
||||
"text": node.get("content", node.get("id", "")),
|
||||
"metadata": node.get("properties", {}),
|
||||
}
|
||||
for n in nodes
|
||||
for node in nodes
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"source": e.get("source"),
|
||||
"target": e.get("target"),
|
||||
"type": e.get("type", "related_to"),
|
||||
"metadata": e.get("metadata", {}),
|
||||
"id": edge.get("id"),
|
||||
"familyId": edge.get("familyId"),
|
||||
"source": edge.get("source"),
|
||||
"target": edge.get("target"),
|
||||
"type": edge.get("type", "related_to"),
|
||||
"weight": edge.get("weight", 1.0),
|
||||
"metadata": edge.get("properties", {}),
|
||||
}
|
||||
for e in edges
|
||||
for edge in edges
|
||||
],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph mutation helpers
|
||||
# ------------------------------------------------------------------
|
||||
def resolve_path_edge_ids(self, path_nodes: List[str]) -> List[str]:
|
||||
if len(path_nodes) < 2:
|
||||
return []
|
||||
|
||||
edge_ids: List[str] = []
|
||||
with self._lock:
|
||||
for index in range(len(path_nodes) - 1):
|
||||
source_id = path_nodes[index]
|
||||
target_id = path_nodes[index + 1]
|
||||
candidates = [
|
||||
edge for edge in self.graph._adjacency.get(source_id, [])
|
||||
if edge.target_id == target_id
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
candidates.sort(
|
||||
key=lambda edge: (
|
||||
-float(edge.weight),
|
||||
str(edge.edge_type),
|
||||
str(edge.edge_id),
|
||||
)
|
||||
)
|
||||
edge_ids.append(str(candidates[0].edge_id))
|
||||
return edge_ids
|
||||
|
||||
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
|
||||
"""Thread-safe node addition."""
|
||||
with self._lock:
|
||||
return self.graph.add_nodes(nodes)
|
||||
|
||||
def add_edges(self, edges: List[Dict[str, Any]]) -> int:
|
||||
"""Thread-safe edge addition."""
|
||||
with self._lock:
|
||||
return self.graph.add_edges(edges)
|
||||
|
||||
@@ -6,9 +6,31 @@ compatible with ContextGraph.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
import importlib.util
|
||||
import rdflib
|
||||
from rdflib.namespace import RDF, RDFS, SKOS
|
||||
|
||||
_HAS_DEFUSEDXML = importlib.util.find_spec("defusedxml") is not None
|
||||
|
||||
|
||||
def _safe_parse_rdf(g: rdflib.Graph, data: bytes, rdf_format: str) -> None:
|
||||
"""Parse RDF bytes into *g*, guarding against XXE for XML-based formats."""
|
||||
xml_formats = {"xml", "rdf", "rdf/xml", "application/rdf+xml"}
|
||||
if rdf_format.lower() in xml_formats:
|
||||
if _HAS_DEFUSEDXML:
|
||||
# defusedxml patches xml.etree so rdflib's XML parser inherits the fix
|
||||
import defusedxml
|
||||
defusedxml.defuse_stdlib()
|
||||
else:
|
||||
# Warn once; best-effort protection via rdflib's own parser
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"defusedxml is not installed. Install it (`pip install defusedxml`) "
|
||||
"to protect RDF/XML parsing against XXE attacks.",
|
||||
stacklevel=4,
|
||||
)
|
||||
g.parse(data=data, format=rdf_format)
|
||||
|
||||
def _get_best_label(graph: rdflib.Graph, subject: rdflib.URIRef, predicate: rdflib.URIRef) -> str:
|
||||
"""
|
||||
Extracts the best available string label for a given predicate.
|
||||
@@ -63,7 +85,7 @@ def parse_skos_file(file_bytes: bytes, rdf_format: str = "turtle") -> Tuple[List
|
||||
g = rdflib.Graph()
|
||||
|
||||
try:
|
||||
g.parse(data=file_bytes, format=rdf_format)
|
||||
_safe_parse_rdf(g, file_bytes, rdf_format)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to parse RDF file as {rdf_format}. Ensure the file is valid. Details: {str(e)}") from e
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -501,18 +502,34 @@ class SnowflakeIngestor:
|
||||
table_ref = self._escape_identifier(table_name)
|
||||
|
||||
query = f"SELECT * FROM {table_ref}"
|
||||
params: list = []
|
||||
|
||||
if where:
|
||||
# where is appended verbatim — callers MUST only pass
|
||||
# trusted, application-controlled predicates here.
|
||||
# Reject obvious injection attempts: multiple statements.
|
||||
if ";" in where:
|
||||
raise ValueError("Invalid WHERE clause: semicolons not permitted.")
|
||||
query += f" WHERE {where}"
|
||||
|
||||
if order_by:
|
||||
# Validate order_by to column names + optional ASC/DESC only.
|
||||
_SAFE_ORDER_RE = re.compile(
|
||||
r'^[A-Za-z_][A-Za-z0-9_]*(\s+(ASC|DESC))?'
|
||||
r'(\s*,\s*[A-Za-z_][A-Za-z0-9_]*(\s+(ASC|DESC))?)*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not _SAFE_ORDER_RE.match(order_by.strip()):
|
||||
raise ValueError(f"Invalid ORDER BY clause: '{order_by}'")
|
||||
query += f" ORDER BY {order_by}"
|
||||
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
if limit is not None:
|
||||
query += " LIMIT %s"
|
||||
params.append(int(limit))
|
||||
|
||||
if offset:
|
||||
query += f" OFFSET {offset}"
|
||||
if offset is not None:
|
||||
query += " OFFSET %s"
|
||||
params.append(int(offset))
|
||||
|
||||
self.logger.debug(f"Executing query: {query}")
|
||||
|
||||
@@ -522,7 +539,7 @@ class SnowflakeIngestor:
|
||||
)
|
||||
|
||||
cursor = conn.cursor(DictCursor)
|
||||
cursor.execute(query)
|
||||
cursor.execute(query, params if params else None)
|
||||
|
||||
# Fetch results
|
||||
self.progress_tracker.update_tracking(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user