Compare commits

..
Author SHA1 Message Date
KaifAhmad1andClaude Sonnet 4.6 e78ad7f819 fix(visualization): accept KnowledgeGraph objects in all visualize_* methods (closes #458)
KGVisualizer.visualize_network() (and sibling methods) only accepted a raw
dict. Passing a KnowledgeGraph object — the natural output of
GraphBuilder.build() — silently returned without rendering.

Added _normalize_graph() which duck-types the input: dicts pass through
unchanged; any object exposing .entities / .relationships attributes is
converted to the canonical dict form; anything else raises a clear
ProcessingError naming the offending type.

_normalize_graph() is called as the first statement in visualize_network(),
visualize_communities(), visualize_centrality(), visualize_entity_types(),
and visualize_relationship_matrix().

Also adds 21 tests in tests/visualization/test_kg_visualizer_normalize_graph.py
covering the helper directly, the end-to-end regression for #458, and
a guard that every public method routes through _normalize_graph.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:04:25 +05:30
Mohd KaifandClaude Sonnet 4.6 fdb347fe8a feat(cookbook): add Datalog-style reasoning end-to-end notebook (#457)
End-to-end example using DatalogReasoner, GraphBuilder, ContextGraph,
GraphAnalyzer, ExplanationGenerator, DatalogFact, and DatalogRule.
Covers ancestor query, KG dependency analysis, RBAC policy, and org hierarchy.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:43:04 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8d0dce13c5 ci(deps): bump softprops/action-gh-release from 1 to 3 (#455)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 15:58:50 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 61c8435bd7 ci(deps): bump actions/github-script from 8 to 9 (#454)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 15:21:15 +05:30
Mohd Kaif b313604bde Merge pull request #452 from Hawksight-AI/security-enhancement
Security Enhancement — Fix 12 Vulnerabilities (CRITICAL → LOW)
2026-04-12 16:08:51 +05:30
Mohd KaifandCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> 5e6df93f64 Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-12 15:56:15 +05:30
Mohd KaifandCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> 920c0e55d5 Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-12 15:34:50 +05:30
Mohd KaifandCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> 7de2a2eb5e Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-12 14:53:52 +05:30
KaifAhmad1andClaude Sonnet 4.6 ce60acb294 docs(changelog): add security-enhancement PR entries to [Unreleased]
Documents all 12 vulnerability fixes (CRITICAL→LOW), 4 post-review bug
fixes, and CodeQL infrastructure changes under [Unreleased] following
the existing Keep a Changelog format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:40:12 +05:30
KaifAhmad1andClaude Sonnet 4.6 4acdefd4b8 fix: address 4 post-review bugs from security-enhancement PR
fix(agent_memory): implement MemoryItem.to_dict() / from_dict() for safe JSON
  persistence — timestamps serialised via isoformat(), embeddings dropped (not
  JSON-safe, regenerated on demand); save() and load() now round-trip correctly
  without TypeError or AttributeError (Bug #1)

fix(sparql): add asyncio.Semaphore(_SPARQL_MAX_CONCURRENT=4) around graph.query
  so timed-out threads cannot exhaust the default ThreadPoolExecutor; add
  `truncated: bool` field to SparqlResponse so callers know when the 5 000-row
  cap was hit (Bug #2)

fix(export_import): trim _ALLOWED_IMPORT_EXTENSIONS to {.json, .csv} — the only
  formats the handler actually parses; removes .graphml/.gexf/.ttl/.rdf that
  passed the allowlist check but hit a hard 422 inside the handler (Bug #3)

fix(codeql): remove blanket rule-ID auto-dismiss job; replace with a commented
  template for pinning specific alert numbers — prevents future real alerts of
  the same rule being silently suppressed (Bug #4)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:35:30 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> a16cb9c468 Potential fix for pull request finding 'Unused import'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-12 14:17:55 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 1bdaad9c59 Potential fix for pull request finding 'Unused import'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-12 14:14:20 +05:30
Mohd KaifandCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> db00a3d1ad Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-12 14:14:04 +05:30
KaifAhmad1andClaude Sonnet 4.6 d8b8ae634b security: fix 12 vulnerabilities across CRITICAL→LOW severity
Closes CodeQL alerts #12, #13, #14, #15, #16, #17, #18

CRITICAL
- fix(media_parser): replace eval() with fractions.Fraction for fps parsing (CWE-95)
- fix(agent_memory): replace pickle serialization with JSON to prevent RCE (CWE-502)

HIGH
- fix(snowflake_ingestor): parameterize LIMIT/OFFSET, validate ORDER BY with regex,
  reject semicolons in WHERE to prevent SQL injection (CWE-89)
- fix(rdf_parser): add defusedxml XXE protection for RDF/XML format parsing (CWE-611)
- fix(server): add CORSMiddleware, security response headers middleware
  (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy,
  Permissions-Policy, HSTS), and global error handler (CWE-346, CWE-200)
- fix(explorer/app): narrow CORS to specific methods/headers, redact exception
  messages in HTTP error handlers, enforce 64 KB WebSocket message size cap (CWE-346)

MEDIUM
- fix(graph): replace free-text algorithm param with _PathAlgorithm enum (CWE-20)
- fix(vocabulary): validate uploaded file extensions against allowlist (CWE-434)
- fix(llm_extraction): json.dumps() all user content in LLM prompts to block
  prompt-injection attacks (CWE-1336)
- fix(pipeline_validator): replace __import__("collections") with proper import (CWE-95)

LOW
- fix(sparql): cap results at 5 000 rows and enforce 30-second query timeout (CWE-400)
- fix(export_import): validate file extension + enforce 50 MB upload limit (CWE-434)

CodeQL / scanning
- feat(codeql): add .github/codeql/codeql-config.yml to exclude generated
  cookbook HTML bundles (Plotly + MapLibre) from JS scanning
- feat(codeql): extend dismiss-fixed-alerts job with all new rule IDs
  (py/path-injection, py/polynomial-redos, js/incomplete-url-substring-sanitization,
  js/insecure-randomness, js/prototype-pollution-utility)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 13:41:31 +05:30
Mohd Kaif cdb26aab3b Merge pull request #420 from ZohaibHassan16/feat/explorer-vocab-ui
feat(explorer): add initial UI for SKOS Vocabulary Workspace
2026-04-11 20:56:42 +05:30
KaifAhmad1andClaude Sonnet 4.6 f4db4469ba chore: untrack remaining generated Vite bundles from git
semantica/static/ is already in .gitignore but the 19 newly-hashed
build artifacts introduced by the main merge were still tracked.
Runs git rm --cached to complete the untracking so future frontend
builds do not create dirty working-tree diffs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:11:38 +05:30
KaifAhmad1andClaude Sonnet 4.6 98453cab5d docs(changelog): add PR #420 explorer blocker and security fix entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:54:21 +05:30
KaifAhmad1andClaude Sonnet 4.6 1fde71768d fix(explorer): resolve blockers and significant issues from PR #420 review
Blockers fixed:
- Rename DockerFile → Dockerfile (case-sensitive fix for Linux CI/Docker)
- Fix Docker CMD: semantica.server:app → semantica.explorer.app:app
- Add module-level app = create_app() so uvicorn can reference the ASGI app
- Remove pre-built static assets from git; add semantica/static/ to .gitignore

Security / correctness fixes:
- Fix CORS default from "*" to localhost:5173 (explicit env var still overrides)
- Add guard to get_ws_manager() — returns 503 instead of AttributeError when unset
- Restrict SPARQL endpoint to read-only query types (SELECT/ASK/CONSTRUCT/DESCRIBE)
- Add 10 MB upload size limit to vocabulary import route
- Add JSON-LD format auto-detection (.jsonld / .json-ld / .json) in vocabulary import

Code quality fixes:
- Replace O(N) annotation scan in create_annotation with O(1) get_annotation() lookup
- Add get_annotation(ann_id) method to GraphSession
- Add self-loop guard in batchMergeEdges (graph has allowSelfLoops: false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:13:40 +05:30
Mohd Kaif e00bdfe8d4 Merge branch 'main' into feat/explorer-vocab-ui 2026-04-11 18:01:45 +05:30
Mohd Kaif 9e31e8d746 Merge pull request #451 from Hawksight-AI/triplet-store
fix(triplet-store): resolve entity/class/property IRIs against ontolo…
2026-04-11 17:09:39 +05:30
Mohd Kaif 745927d674 Merge pull request #450 from Hawksight-AI/triplet-store
Fix Blazegraph literal serialization in bulk loader (Fixes #448)
2026-04-11 15:28:50 +05:30
Zohaib Hassnain dfd7785cc1 feat: overhaul graph explorer visuals and loading flow 2026-04-11 03:19:28 +05:00
Zohaib Hassnain 6e6b190da1 perf(explore): split GraphWorkspace into lazy subchunks 2026-04-09 14:22:15 +05:00
Zohaib Hassnain e03ba5685c feat(graph): add opt-in exploration effects panel 2026-04-09 13:59:44 +05:00
Zohaib Hassnain 1af17f3398 feat: productized explorer workspace 2026-04-09 03:17:12 +05:00
Zohaib Hassnain c964e11d38 feat(graph): add rich element rendering system 2026-04-09 02:42:40 +05:00
Zohaib Hassnain 8829aa5ce2 feat(graph): add plugin host for graph tools 2026-04-09 02:21:35 +05:00
Zohaib Hassnain 102274c668 refactor(graph): add typed theme system and first-class behavior modules 2026-04-09 01:30:33 +05:00
Zohaib Hassnain 38b766298e feat(explorer): harden knowledge explorer backend and frontend, polish dashboard UX 2026-04-07 02:04:17 +05:00
Zohaib Hassnain c0f106dc1e feat(explorer): implement Phase 4 & 5 : Temporal Engine and Power-User Suite
Phase 4: Time Travel & Decisions
- Integrated Temporal Scrubber (TimelinePanel.tsx) with high-speed WebGL filtering.
- Implemented Decision Tree Viewer with recursive causal chain visualization.

Phase 5: Power-User Tools
- Built SPARQL Engine with Monaco Editor UI and rdflib backend integration.
- Implemented PROV-O Lineage swimlanes using React Flow with custom layout math.
- Developed side-by-side Entity Diff/Merge tool with Amber-highlighting.
- Expanded Import/Export suite for robust JSON/CSV dataset ingestion.
- Refactored temporal routes for delta-only ID snapshots.
2026-04-05 15:05:53 -07:00
ZohaibHassan16 98a2cf9490 feat(ui): complete graph visualization overhaul
This commit transforms the raw 150k-element graph into a high-performance, exploratory UI:

- Implemented Universal Sizing (logarithmic scale based on node degree) and a Procedural Color Mapper (string hashing) to automatically size and colorize categorical data.
- Built the 'Focus Mode' engine using Sigma reducers. Hovering or clicking a node instantly isolates it and its 1-hop neighbors while muting the canvas, eliminating visual noise.
- Applied an enterprise-grade visual style, featuring deep radial background gradients, structural grid overlays, and a sliding glassmorphism metadata HUD.
- Shifted from DOM-bound state mutations to direct WebGL render pipelines to maintain visual performance.
2026-04-03 00:20:52 +05:00
ZohaibHassan16 9203c2d684 feat(ui): complete phase 2 massive graph rendering and api alignment 2026-04-01 23:51:42 +05:00
ZohaibHassan16 719063e781 Merge branch 'fix/cg-pagination' into feat/explorer-vocab-ui 2026-04-01 12:18:08 +05:00
ZohaibHassan16 60c00fb5c2 feat(explorer): implement phase 1: single-server deployment and dockerization 2026-03-31 13:47:49 +05:00
ZohaibHassan16 1b277dcdd7 feat(ui): wire TanStack query, update UI types, and configure Vite proxy 2026-03-31 04:43:25 +05:00
ZohaibHassan16 3065f3c00e feat(explorer): add initial UI for SKOS Vocabulary Workspace 2026-03-31 02:24:10 +05:00
108 changed files with 23021 additions and 1928 deletions
+11
View File
@@ -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"
+17 -35
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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');
+3
View File
@@ -110,3 +110,6 @@ sample_data/
# Test Results
test_results.txt
# Frontend build artifacts (generated by Vite — do not track in git)
semantica/static/
+39
View File
@@ -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
View File
@@ -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": [
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](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
}
+24
View File
@@ -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?
+75
View File
@@ -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...
},
},
])
```
+23
View File
@@ -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,
},
},
])
+13
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+51
View File
@@ -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

+24
View File
@@ -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

View File
+432
View File
@@ -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

+1
View File
@@ -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

+65
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<App />,
)
+2
View File
@@ -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]);
}
+168
View File
@@ -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();
}
+16
View File
@@ -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;
}
}
+384
View File
@@ -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;
}
}
+170
View File
@@ -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);
}
@@ -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,
};
@@ -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"]));
});
+28
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -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"]
}
+63
View File
@@ -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,
},
},
},
})
+63 -16
View File
@@ -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},
+355 -72
View File
@@ -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"),
)
+7
View File
@@ -0,0 +1,7 @@
"""Module entry point for ``python -m semantica.explorer``."""
from . import main
if __name__ == "__main__":
main()
+81 -46
View File
@@ -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 -1
View File
@@ -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
+13 -42
View File
@@ -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,
+3 -8
View File
@@ -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)
+51 -82
View File
@@ -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(
+251 -70
View File
@@ -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)
+207 -207
View File
@@ -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
+75 -102
View File
@@ -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
],
}
+170
View File
@@ -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"'},
)
+141
View File
@@ -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)
+76 -84
View File
@@ -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)
+194 -99
View File
@@ -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,
)
+75 -62
View File
@@ -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
View File
@@ -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)
+23 -1
View File
@@ -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
+22 -5
View File
@@ -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