mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-02 04:00:40 +00:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e6df93f64 | ||
|
|
920c0e55d5 | ||
|
|
7de2a2eb5e | ||
|
|
ce60acb294 | ||
|
|
4acdefd4b8 | ||
|
|
a16cb9c468 | ||
|
|
1bdaad9c59 | ||
|
|
db00a3d1ad | ||
|
|
d8b8ae634b | ||
|
|
cdb26aab3b | ||
|
|
f4db4469ba | ||
|
|
98453cab5d | ||
|
|
1fde71768d | ||
|
|
e00bdfe8d4 | ||
|
|
9e31e8d746 | ||
|
|
9d680d4369 | ||
|
|
9d0744e20e | ||
|
|
0b52b715dc | ||
|
|
745927d674 | ||
|
|
af401c8566 | ||
|
|
2e2dae558f | ||
|
|
3a1a798107 | ||
|
|
a4b17dd72b | ||
|
|
9366f07239 | ||
|
|
61676fb321 | ||
|
|
1ea5e5c012 | ||
|
|
490d9c814b | ||
|
|
d2c20d410c | ||
|
|
67a8ab1a8e | ||
|
|
dfd7785cc1 | ||
|
|
ac4a200f26 | ||
|
|
c1f0cf6f34 | ||
|
|
665f9c080e | ||
|
|
6e6b190da1 | ||
|
|
e03ba5685c | ||
|
|
8d32932322 | ||
|
|
7a5e8fd981 | ||
|
|
082ab14d2e | ||
|
|
14d350378f | ||
|
|
241a24d75d | ||
|
|
b2eb5db87f | ||
|
|
74d5980215 | ||
|
|
1af17f3398 | ||
|
|
c964e11d38 | ||
|
|
8829aa5ce2 | ||
|
|
102274c668 | ||
|
|
3b400eb88b | ||
|
|
678d891b42 | ||
|
|
e60cef9eb7 | ||
|
|
79a980d956 | ||
|
|
47828cff0d | ||
|
|
17289121cb | ||
|
|
b670bc32a4 | ||
|
|
5af6e383ad | ||
|
|
cd70481034 | ||
|
|
1cf13d0188 | ||
|
|
069af2a038 | ||
|
|
35ebccbdd7 | ||
|
|
de432d5eb4 | ||
|
|
38b766298e | ||
|
|
c0f106dc1e | ||
|
|
98a2cf9490 | ||
|
|
9203c2d684 | ||
|
|
719063e781 | ||
|
|
60c00fb5c2 | ||
|
|
1b277dcdd7 | ||
|
|
3065f3c00e | ||
|
|
129edaf05b |
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: semantica
|
||||
description: Semantica full-stack knowledge graph skill for context graphs, decision intelligence, explainability, extraction, reasoning, visualization, ontology, provenance, policy, and export workflows.
|
||||
---
|
||||
|
||||
# Semantica
|
||||
|
||||
This Skill helps Claude apply Semantica knowledge graph capabilities to context graph analysis, decision intelligence, explainability, semantic extraction, graph analytics, reasoning, provenance, ontology, policy, ingestion, deduplication, and export.
|
||||
|
||||
## When to use this Skill
|
||||
|
||||
- The user asks about knowledge graphs, entities, relations, triplets, or semantic extraction.
|
||||
- A task requires context graph analysis, graph topology, centrality, communities, paths, or embeddings.
|
||||
- The request involves decision intelligence, causal influence, decision graphs, or outcome analysis.
|
||||
- The user asks for explainability, decision rationale, or transparency for graph results.
|
||||
- The request involves reasoning: deductive, abductive, SPARQL, Datalog, or Rete rules.
|
||||
- The user needs provenance, audit history, lineage tracking, or change tracing.
|
||||
- The request is about ontology modeling, schema validation, or policy enforcement.
|
||||
- Data must be ingested from files, databases, APIs, repositories, or MCP servers.
|
||||
- There is a need to deduplicate entities, normalize graph data, or merge duplicate graph objects.
|
||||
- The user wants to export graphs to JSON, RDF, Parquet, CSV, GraphML, or similar.
|
||||
|
||||
## What this Skill contains
|
||||
|
||||
- Semantic extraction guidance for NER, relation extraction, event detection, coreference resolution, and triplet generation.
|
||||
- Context graph and graph analytics workflows for topology, centrality, community detection, path finding, embeddings, and decision insights.
|
||||
- Decision intelligence support for causal reasoning, decision impact, decision graphs, and outcome analysis.
|
||||
- Explainability guidance for decision rationale, graph reasoning, rule traces, and result transparency.
|
||||
- Reasoning support for logic, hypotheses, SPARQL, Datalog, and rule-based inference.
|
||||
- Provenance and audit guidance for tracing sources, recording changes, and verifying graph lineage.
|
||||
- Ontology guidance for defining concepts, validating schemas, and modeling relationships.
|
||||
- Policy checks for compliance evaluation and graph governance.
|
||||
- Temporal analysis guidance for event timelines and graph evolution.
|
||||
- Deduplication support for duplicate detection, fuzzy matching, and graph cleanup.
|
||||
- Export workflows for sharing results in multiple structured formats.
|
||||
|
||||
## Best prompt patterns
|
||||
|
||||
Use clear task descriptions, and mention the desired output format when possible.
|
||||
|
||||
- "Extract entities, relations, and events from this text and summarize the resulting graph."
|
||||
- "Analyze this context graph and show the top 5 most influential nodes."
|
||||
- "Generate a decision intelligence report with causal impact and explainability."
|
||||
- "Run a provenance trace for node X and describe its history."
|
||||
- "Validate the ontology for this graph and report any schema problems."
|
||||
- "Ingest the data from this MCP server and merge it into the current graph."
|
||||
- "Export the graph to JSON and GraphML with node and edge metadata."
|
||||
|
||||
## How Claude should use this Skill
|
||||
|
||||
1. Read the YAML metadata and identify whether the request matches Semantica graph, context graph, decision intelligence, or extraction tasks.
|
||||
2. Load this Skill when the request mentions Semantica, knowledge graphs, context graphs, decision intelligence, explainability, reasoning, or provenance.
|
||||
3. Use the instructions here to choose the right workflow and then read additional files or scripts only if needed.
|
||||
|
||||
## Authoring note
|
||||
|
||||
This Skill is purposely concise and focused on task selection. It is not intended to include every detail; Claude should use the filesystem-based model to load any extra reference files only when asked.
|
||||
@@ -0,0 +1,11 @@
|
||||
name: "Semantica CodeQL Config"
|
||||
|
||||
# Exclude auto-generated notebook exports and bundled third-party JS.
|
||||
# Files in cookbook/**/*.html are self-contained Plotly/MapLibre bundles
|
||||
# produced by Jupyter nbconvert — they embed minified third-party libraries
|
||||
# (Plotly, MapLibre GL JS) whose internal patterns trigger false-positive JS
|
||||
# alerts (js/incomplete-url-substring-sanitization, js/insecure-randomness,
|
||||
# js/prototype-pollution-utility). These are not application code.
|
||||
paths-ignore:
|
||||
- "cookbook/**/*.html"
|
||||
- "cookbook/**/*.js"
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
with:
|
||||
languages: python
|
||||
queries: security-and-quality
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
@@ -49,38 +50,19 @@ jobs:
|
||||
wait-for-processing: true
|
||||
continue-on-error: true
|
||||
|
||||
dismiss-fixed-alerts:
|
||||
name: Dismiss Fixed Security Alerts
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Dismiss resolved CodeQL alerts via API
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
FIXED_PATTERNS=(
|
||||
"py/clear-text-logging-sensitive-data"
|
||||
"py/incomplete-url-substring-sanitization"
|
||||
"actions/missing-workflow-permissions"
|
||||
)
|
||||
|
||||
# Fetch all open code scanning alerts
|
||||
ALERTS=$(gh api repos/$REPO/code-scanning/alerts \
|
||||
--jq '.[] | {number: .number, rule: .rule.id, state: .state}' \
|
||||
-X GET -f state=open -f per_page=100)
|
||||
|
||||
for PATTERN in "${FIXED_PATTERNS[@]}"; do
|
||||
ALERT_NUMS=$(echo "$ALERTS" | jq -r \
|
||||
"select(.rule == \"$PATTERN\") | .number")
|
||||
for NUM in $ALERT_NUMS; do
|
||||
echo "Dismissing alert #$NUM ($PATTERN) — fixed in security-enhancement PR"
|
||||
gh api repos/$REPO/code-scanning/alerts/$NUM \
|
||||
-X PATCH \
|
||||
-f state=dismissed \
|
||||
-f dismissed_reason="won't fix" \
|
||||
-f dismissed_comment="Fixed in PR security-enhancement: code changes remove the vulnerability. Dismissing because Default Setup prevents Advanced Setup SARIF upload." \
|
||||
&& echo " ✓ Alert #$NUM dismissed" \
|
||||
|| echo " ⚠ Could not dismiss alert #$NUM (may already be closed)"
|
||||
done
|
||||
done
|
||||
# NOTE: Auto-dismissal by rule-id is intentionally removed.
|
||||
# Dismissing every alert that matches a rule ID would silently suppress
|
||||
# future real vulnerabilities of the same type. The alerts below were
|
||||
# individually triaged and dismissed manually in the security-enhancement
|
||||
# PR (alerts #12–#18). New alerts must be reviewed and dismissed by hand,
|
||||
# or will auto-close when the underlying code no longer triggers them.
|
||||
#
|
||||
# If you need to dismiss a specific known-safe alert, pin its alert NUMBER
|
||||
# here and remove it once CodeQL stops reporting it naturally. Example:
|
||||
#
|
||||
# PINNED_ALERT_NUMBERS=(12 13 14 15 16 17 18)
|
||||
# for NUM in "${PINNED_ALERT_NUMBERS[@]}"; do
|
||||
# gh api repos/$REPO/code-scanning/alerts/$NUM \
|
||||
# -X PATCH -f state=dismissed -f dismissed_reason="false positive" \
|
||||
# -f dismissed_comment="<reason>"
|
||||
# done
|
||||
|
||||
@@ -110,3 +110,6 @@ sample_data/
|
||||
|
||||
# Test Results
|
||||
test_results.txt
|
||||
|
||||
# Frontend build artifacts (generated by Vite — do not track in git)
|
||||
semantica/static/
|
||||
|
||||
+1172
-31
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/semantica-explorer
|
||||
|
||||
|
||||
COPY semantica-explorer/package.json semantica-explorer/package-lock.json* ./
|
||||
|
||||
|
||||
RUN npm install
|
||||
|
||||
|
||||
COPY semantica-explorer/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY semantica/ ./semantica/
|
||||
|
||||
COPY --from=frontend-builder /app/semantica/static ./semantica/static
|
||||
|
||||
RUN pip install --no-cache-dir ".[explorer]"
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "semantica.explorer.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.10.0"
|
||||
}
|
||||
},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-0",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[](https://colab.research.google.com/github/Hawksight-AI/semantica/blob/main/cookbook/advanced/13_Manual_Ontology_Snowflake_Mapping.ipynb)\n",
|
||||
"\n",
|
||||
"# Manual Ontology + Snowflake Mapping\n",
|
||||
"\n",
|
||||
"This notebook answers a specific workflow:\n",
|
||||
"\n",
|
||||
"> *\"I want to design the ontology myself — not have AI infer it from my tables — and then map Snowflake data to it explicitly.\"*\n",
|
||||
"\n",
|
||||
"### What this notebook demonstrates\n",
|
||||
"\n",
|
||||
"| Step | What happens | Who controls it |\n",
|
||||
"|---|---|---|\n",
|
||||
"| 1 | Design ontology classes and properties | **You** (Python dict) |\n",
|
||||
"| 2 | Model n-ary facts with reification | **You** (`AssociativeClassBuilder`) |\n",
|
||||
"| 3 | Pull rows from Snowflake | Semantica `SnowflakeIngestor` |\n",
|
||||
"| 4 | Map columns → ontology-aligned graph | **You** (explicit transform) |\n",
|
||||
"| 5 | Validate + export OWL / SHACL | Semantica `OntologyEngine` |\n",
|
||||
"| 6 | Load to triplet store and query | Semantica `TripletStore` |\n",
|
||||
"\n",
|
||||
"### What this notebook does NOT do\n",
|
||||
"\n",
|
||||
"- No LLM-driven ontology generation\n",
|
||||
"- No schema introspection or table-to-class inference\n",
|
||||
"- No \"suggest ontology from my data\"\n",
|
||||
"\n",
|
||||
"### Standards coverage\n",
|
||||
"\n",
|
||||
"| Feature | Status |\n",
|
||||
"|---|---|\n",
|
||||
"| OWL 2 (Turtle / RDF-XML) | Supported |\n",
|
||||
"| SHACL 1.1 shapes | Supported |\n",
|
||||
"| SPARQL 1.1 | Supported |\n",
|
||||
"| Reification / n-ary facts | Supported via `AssociativeClassBuilder` |\n",
|
||||
"| SPARQL 1.2 (reifier annotation, `LATERAL`) | Planned |\n",
|
||||
"| SHACL 1.2 (`sh:severity` extensions, SHACL-AF) | Planned |"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -qU semantica"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-2",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"from typing import Any, Dict, List\n",
|
||||
"\n",
|
||||
"from semantica.ingest import SnowflakeIngestor\n",
|
||||
"from semantica.kg.methods import build_kg\n",
|
||||
"from semantica.ontology import AssociativeClassBuilder, OntologyEngine\n",
|
||||
"from semantica.triplet_store import TripletStore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-3",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 1: Hand-Design the Ontology in Python\n",
|
||||
"\n",
|
||||
"You define every class and property explicitly. Nothing is read from Snowflake at this stage.\n",
|
||||
"\n",
|
||||
"**Design decisions that belong to you:**\n",
|
||||
"- Which classes exist and what they mean\n",
|
||||
"- Which properties are datatype vs. object properties\n",
|
||||
"- Domain, range, and cardinality constraints\n",
|
||||
"- Which properties are required (later enforced by SHACL)\n",
|
||||
"\n",
|
||||
"This dict versions with your code. It does not change when your database schema changes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-4",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "BASE_URI = \"https://example.com/hr/\"\n\n# Your ontology — designed by you, not inferred by Semantica.\nontology: Dict[str, Any] = {\n \"name\": \"EmploymentDomainOntology\",\n \"uri\": f\"{BASE_URI}EmploymentDomainOntology\",\n \"namespace\": {\"base_uri\": BASE_URI},\n\n # You decide the class taxonomy\n \"classes\": [\n {\"name\": \"Person\", \"uri\": f\"{BASE_URI}Person\"},\n {\"name\": \"Organization\", \"uri\": f\"{BASE_URI}Organization\"},\n {\"name\": \"Role\", \"uri\": f\"{BASE_URI}Role\"},\n # EmploymentEvent is a reification node.\n # It connects Person + Organization + Role and carries salary/date context.\n {\"name\": \"EmploymentEvent\", \"uri\": f\"{BASE_URI}EmploymentEvent\"},\n ],\n\n # Each property carries a full URI so TripletStore stores it as hr:<name>\n # rather than the default urn:property:<name>.\n # This ensures SPARQL queries using PREFIX hr: match what is actually stored.\n \"properties\": [\n # Datatype properties\n {\"name\": \"name\", \"uri\": f\"{BASE_URI}name\", \"type\": \"datatype\", \"domain\": \"Person\", \"range\": \"string\", \"required\": True},\n {\"name\": \"legalName\", \"uri\": f\"{BASE_URI}legalName\", \"type\": \"datatype\", \"domain\": \"Organization\", \"range\": \"string\", \"required\": True},\n {\"name\": \"title\", \"uri\": f\"{BASE_URI}title\", \"type\": \"datatype\", \"domain\": \"Role\", \"range\": \"string\", \"required\": True},\n {\"name\": \"startDate\", \"uri\": f\"{BASE_URI}startDate\", \"type\": \"datatype\", \"domain\": \"EmploymentEvent\", \"range\": \"date\"},\n {\"name\": \"endDate\", \"uri\": f\"{BASE_URI}endDate\", \"type\": \"datatype\", \"domain\": \"EmploymentEvent\", \"range\": \"date\"},\n {\"name\": \"salary\", \"uri\": f\"{BASE_URI}salary\", \"type\": \"datatype\", \"domain\": \"EmploymentEvent\", \"range\": \"decimal\"},\n\n # Object properties — reification spokes (required)\n {\"name\": \"employee\", \"uri\": f\"{BASE_URI}employee\", \"type\": \"object\", \"domain\": \"EmploymentEvent\", \"range\": \"Person\", \"required\": True},\n {\"name\": \"employer\", \"uri\": f\"{BASE_URI}employer\", \"type\": \"object\", \"domain\": \"EmploymentEvent\", \"range\": \"Organization\", \"required\": True},\n {\"name\": \"role\", \"uri\": f\"{BASE_URI}role\", \"type\": \"object\", \"domain\": \"EmploymentEvent\", \"range\": \"Role\", \"required\": True},\n\n # Shortcut edges — direct person→org / person→role without traversing the event node\n {\"name\": \"worksFor\", \"uri\": f\"{BASE_URI}worksFor\", \"type\": \"object\", \"domain\": \"Person\", \"range\": \"Organization\"},\n {\"name\": \"hasRole\", \"uri\": f\"{BASE_URI}hasRole\", \"type\": \"object\", \"domain\": \"Person\", \"range\": \"Role\"},\n ],\n}\n\nontology"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-5",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 2: Reification — Modeling N-Ary Facts\n",
|
||||
"\n",
|
||||
"**The problem with binary triples:**\n",
|
||||
"A simple triple `(Alice, worksFor, Acme)` cannot carry extra context such as salary, start date, or role.\n",
|
||||
"Standard RDF reification and OWL n-ary patterns solve this by introducing an intermediate node.\n",
|
||||
"\n",
|
||||
"Semantica's `AssociativeClassBuilder` is the Pythonic API for this pattern:\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"EmploymentEvent\n",
|
||||
" ├── employee → Person (required)\n",
|
||||
" ├── employer → Organization (required)\n",
|
||||
" ├── role → Role (required)\n",
|
||||
" ├── startDate → xsd:date\n",
|
||||
" ├── endDate → xsd:date\n",
|
||||
" └── salary → xsd:decimal\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"**On SPARQL 1.1 vs. SPARQL 1.2:**\n",
|
||||
"- **SPARQL 1.1 (current):** traverse the event node explicitly — `?event hr:employee ?person ; hr:salary ?salary`\n",
|
||||
"- **SPARQL 1.2 (planned):** the draft reifier annotation syntax allows attaching context to triples directly, without a separate intermediate node. Semantica will adopt this once the spec is ratified.\n",
|
||||
"\n",
|
||||
"**On SHACL 1.1 vs. SHACL 1.2:**\n",
|
||||
"- **SHACL 1.1 (current):** `sh:NodeShape` + `sh:PropertyShape` constraints are exported for all `required` properties and enforced at load time.\n",
|
||||
"- **SHACL 1.2 (planned):** `sh:severity` profile extensions and SHACL-AF rules are on the roadmap."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-6",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "assoc_builder = AssociativeClassBuilder()\n\nemployment_assoc = assoc_builder.create_associative_class(\n name=\"EmploymentEvent\",\n connects=[\"Person\", \"Organization\", \"Role\"],\n temporal=True, # adds startDate / endDate handling\n properties={\n \"startDate\": \"xsd:date\",\n \"endDate\": \"xsd:date\",\n \"salary\": \"xsd:decimal\",\n },\n)\n\nvalidation_result = assoc_builder.validate_associative_class(employment_assoc)\n\n# AssociativeClass is a dataclass — use attribute access, not .get()\nprint(\"AssociativeClass structure:\")\nprint(f\" name: {employment_assoc.name}\")\nprint(f\" connects: {employment_assoc.connects}\")\nprint(f\" temporal: {employment_assoc.temporal}\")\nprint(f\" properties: {list(employment_assoc.properties.keys())}\")\nprint(f\"\\nValidation passed: {validation_result}\")"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-7",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 3: Ingest Snowflake Rows (Extraction Only)\n",
|
||||
"\n",
|
||||
"`SnowflakeIngestor` retrieves rows — nothing more. It does **not**:\n",
|
||||
"- Inspect your table schema\n",
|
||||
"- Suggest classes or properties\n",
|
||||
"- Infer relationships from column names\n",
|
||||
"\n",
|
||||
"Set `USE_LIVE_SNOWFLAKE=true` plus the env vars below to connect to a real warehouse.\n",
|
||||
"Otherwise the stub data is used."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-8",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def fetch_rows_from_snowflake() -> List[Dict[str, Any]]:\n",
|
||||
" if os.getenv(\"USE_LIVE_SNOWFLAKE\", \"false\").lower() != \"true\":\n",
|
||||
" return [\n",
|
||||
" {\n",
|
||||
" \"EMPLOYEE_ID\": \"E100\",\n",
|
||||
" \"EMPLOYEE_NAME\": \"Alice Johnson\",\n",
|
||||
" \"ORG_ID\": \"O10\",\n",
|
||||
" \"ORG_NAME\": \"Acme Corp\",\n",
|
||||
" \"ROLE_ID\": \"R7\",\n",
|
||||
" \"ROLE_TITLE\": \"Senior Engineer\",\n",
|
||||
" \"START_DATE\": \"2025-01-15\",\n",
|
||||
" \"END_DATE\": None,\n",
|
||||
" \"SALARY\": 160000,\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"EMPLOYEE_ID\": \"E101\",\n",
|
||||
" \"EMPLOYEE_NAME\": \"Bob Singh\",\n",
|
||||
" \"ORG_ID\": \"O10\",\n",
|
||||
" \"ORG_NAME\": \"Acme Corp\",\n",
|
||||
" \"ROLE_ID\": \"R9\",\n",
|
||||
" \"ROLE_TITLE\": \"Data Architect\",\n",
|
||||
" \"START_DATE\": \"2024-09-01\",\n",
|
||||
" \"END_DATE\": None,\n",
|
||||
" \"SALARY\": 185000,\n",
|
||||
" },\n",
|
||||
" ]\n",
|
||||
"\n",
|
||||
" ingestor = SnowflakeIngestor(\n",
|
||||
" account=os.getenv(\"SNOWFLAKE_ACCOUNT\"),\n",
|
||||
" user=os.getenv(\"SNOWFLAKE_USER\"),\n",
|
||||
" password=os.getenv(\"SNOWFLAKE_PASSWORD\"),\n",
|
||||
" warehouse=os.getenv(\"SNOWFLAKE_WAREHOUSE\"),\n",
|
||||
" database=os.getenv(\"SNOWFLAKE_DATABASE\"),\n",
|
||||
" schema=os.getenv(\"SNOWFLAKE_SCHEMA\", \"PUBLIC\"),\n",
|
||||
" )\n",
|
||||
" query = (\n",
|
||||
" \"SELECT EMPLOYEE_ID, EMPLOYEE_NAME, \"\n",
|
||||
" \"ORG_ID, ORG_NAME, ROLE_ID, ROLE_TITLE, \"\n",
|
||||
" \"START_DATE, END_DATE, SALARY \"\n",
|
||||
" \"FROM HR_EMPLOYMENT_FACT\"\n",
|
||||
" )\n",
|
||||
" data = ingestor.ingest_query(query)\n",
|
||||
" ingestor.close()\n",
|
||||
" return data.data\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"rows = fetch_rows_from_snowflake()\n",
|
||||
"rows[:2]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-9",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 4: Map Rows to Ontology Concepts Explicitly\n",
|
||||
"\n",
|
||||
"This is the semantic transformation layer — the part that makes your ontology real.\n",
|
||||
"\n",
|
||||
"Semantica does not guess which column becomes which entity or property.\n",
|
||||
"Every assignment is code you write and own:\n",
|
||||
"\n",
|
||||
"- **Stable node IDs** — deterministic, collision-safe, derived from business keys\n",
|
||||
"- **Class assignment** — matches what you declared in Step 1\n",
|
||||
"- **Property routing** — each column value goes to the correct ontology property\n",
|
||||
"- **Reification wiring** — `EmploymentEvent` is linked to its three participants\n",
|
||||
"\n",
|
||||
"When your Snowflake schema changes, only this function needs updating. The ontology stays stable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-10",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "def map_rows_to_kg(rows: List[Dict[str, Any]]) -> Dict[str, Any]:\n entities: Dict[str, Dict[str, Any]] = {}\n relationships: List[Dict[str, Any]] = []\n\n for row in rows:\n # Stable, deterministic node IDs derived from business keys\n person_id = f\"person:{row['EMPLOYEE_ID']}\"\n org_id = f\"org:{row['ORG_ID']}\"\n role_id = f\"role:{row['ROLE_ID']}\"\n # Event ID includes all three participants + start date so that\n # a re-hired employee gets a distinct event node, not an overwrite.\n event_id = f\"employment:{row['EMPLOYEE_ID']}:{row['ORG_ID']}:{row['START_DATE']}\"\n\n # Entities — \"type\" must match a class name from Step 1\n entities[person_id] = {\n \"id\": person_id,\n \"type\": \"Person\",\n \"properties\": {\"name\": row[\"EMPLOYEE_NAME\"]},\n }\n entities[org_id] = {\n \"id\": org_id,\n \"type\": \"Organization\",\n \"properties\": {\"legalName\": row[\"ORG_NAME\"]},\n }\n entities[role_id] = {\n \"id\": role_id,\n \"type\": \"Role\",\n \"properties\": {\"title\": row[\"ROLE_TITLE\"]},\n }\n\n # Reification node — filter out None values so TripletStore does not\n # stringify None as the literal \"None\" for open-ended employment.\n event_props = {\n \"startDate\": row[\"START_DATE\"],\n \"endDate\": row[\"END_DATE\"],\n \"salary\": row[\"SALARY\"],\n }\n entities[event_id] = {\n \"id\": event_id,\n \"type\": \"EmploymentEvent\",\n \"properties\": {k: v for k, v in event_props.items() if v is not None},\n }\n\n # Full URIs for relationship types so TripletStore stores hr:<type>\n # instead of the default urn:property:<type>, keeping SPARQL consistent.\n relationships.extend([\n # Shortcut edges — fast SPARQL when context is not needed\n {\"source\": person_id, \"target\": org_id, \"type\": f\"{BASE_URI}worksFor\"},\n {\"source\": person_id, \"target\": role_id, \"type\": f\"{BASE_URI}hasRole\"},\n # Reification spokes — full context via the event node\n {\"source\": event_id, \"target\": person_id, \"type\": f\"{BASE_URI}employee\"},\n {\"source\": event_id, \"target\": org_id, \"type\": f\"{BASE_URI}employer\"},\n {\"source\": event_id, \"target\": role_id, \"type\": f\"{BASE_URI}role\"},\n ])\n\n return build_kg([{\"entities\": list(entities.values()), \"relationships\": relationships}])\n\n\nkg = map_rows_to_kg(rows)\nprint(f\"Entities built: {len(kg.get('entities', []))}\")\nprint(f\"Relationships built: {len(kg.get('relationships', []))}\")\n\nsample = next((e for e in kg[\"entities\"] if e[\"type\"] == \"EmploymentEvent\"), None)\nprint(f\"\\nSample EmploymentEvent node: {sample}\")"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-11",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 5: Validate Ontology and Export OWL + SHACL\n",
|
||||
"\n",
|
||||
"`OntologyEngine` validates your ontology dict and serialises it to standards-compliant files.\n",
|
||||
"\n",
|
||||
"**Output files:**\n",
|
||||
"- `employment_manual_ontology.ttl` — OWL 2 Turtle\n",
|
||||
"- `employment_manual_shapes.ttl` — SHACL 1.1 node and property shapes\n",
|
||||
"\n",
|
||||
"**Standards status:**\n",
|
||||
"\n",
|
||||
"| Standard | Semantica support |\n",
|
||||
"|---|---|\n",
|
||||
"| SPARQL 1.1 | Full |\n",
|
||||
"| SHACL 1.1 (`sh:NodeShape`, `sh:PropertyShape`, `sh:minCount`, `sh:datatype`, `sh:class`) | Full |\n",
|
||||
"| SPARQL 1.2 (reifier annotation syntax, `LATERAL`) | Tracked — not yet implemented |\n",
|
||||
"| SHACL 1.2 (`sh:severity` profiles, SHACL-AF extensions) | Tracked — not yet implemented |"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-12",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"engine = OntologyEngine(base_uri=BASE_URI)\n",
|
||||
"\n",
|
||||
"validation = engine.validate(ontology)\n",
|
||||
"owl_ttl = engine.to_owl(ontology, format=\"turtle\")\n",
|
||||
"shacl_ttl = engine.to_shacl(ontology, format=\"turtle\")\n",
|
||||
"\n",
|
||||
"engine.export_owl(ontology, \"employment_manual_ontology.ttl\", format=\"turtle\")\n",
|
||||
"engine.export_shacl(ontology, \"employment_manual_shapes.ttl\", format=\"turtle\")\n",
|
||||
"\n",
|
||||
"print(f\"Ontology valid: {validation.valid}\")\n",
|
||||
"print(f\"Ontology consistent: {validation.consistent}\")\n",
|
||||
"print(f\"OWL output: {len(owl_ttl):,} chars → employment_manual_ontology.ttl\")\n",
|
||||
"print(f\"SHACL output: {len(shacl_ttl):,} chars → employment_manual_shapes.ttl\")\n",
|
||||
"\n",
|
||||
"print(\"\\n--- SHACL shapes (first 20 lines) ---\")\n",
|
||||
"print(\"\\n\".join(shacl_ttl.splitlines()[:20]))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-13",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Best-Practice Architecture\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"┌──────────────────────────────────┐\n",
|
||||
"│ Ontology as code (Python dict) │ ← versioned alongside your application\n",
|
||||
"│ + AssociativeClass for n-ary │\n",
|
||||
"└───────────────┬──────────────────┘\n",
|
||||
" │ validate + export\n",
|
||||
" ▼\n",
|
||||
"┌───────────────────────────────────┐\n",
|
||||
"│ OWL 2 Turtle │ SHACL 1.1 │ ← standards-compliant artifacts\n",
|
||||
"└───────────────┬───────────────────┘\n",
|
||||
" │\n",
|
||||
" ▼\n",
|
||||
"┌──────────────────────────────────┐\n",
|
||||
"│ Snowflake — raw data access │ ← no schema introspection\n",
|
||||
"└───────────────┬──────────────────┘\n",
|
||||
" │ explicit mapping layer\n",
|
||||
" ▼\n",
|
||||
"┌──────────────────────────────────┐\n",
|
||||
"│ Ontology-aligned KG │ ← types, IDs, edges match Step 1\n",
|
||||
"└───────────────┬──────────────────┘\n",
|
||||
" │ optional\n",
|
||||
" ▼\n",
|
||||
"┌──────────────────────────────────┐\n",
|
||||
"│ Triplet store + SPARQL 1.1 │\n",
|
||||
"└──────────────────────────────────┘\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"**Why this split matters:**\n",
|
||||
"If Semantica inferred the ontology from your Snowflake schema, every schema migration would risk silently changing your semantic model.\n",
|
||||
"With this pattern, schema changes only touch the mapping function in Step 4 — the ontology remains stable and under your control."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-14",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## SPARQL Query Patterns\n",
|
||||
"\n",
|
||||
"Two query styles are available because we wrote both shortcut edges and reification spokes.\n",
|
||||
"\n",
|
||||
"### Simple lookup — shortcut edge (no context needed)\n",
|
||||
"\n",
|
||||
"```sparql\n",
|
||||
"PREFIX hr: <https://example.com/hr/>\n",
|
||||
"\n",
|
||||
"SELECT ?personName ?orgName\n",
|
||||
"WHERE {\n",
|
||||
" ?person a hr:Person ;\n",
|
||||
" hr:name ?personName ;\n",
|
||||
" hr:worksFor ?org .\n",
|
||||
" ?org hr:legalName ?orgName .\n",
|
||||
"}\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"### Contextual lookup — via reification node (salary, dates, role)\n",
|
||||
"\n",
|
||||
"```sparql\n",
|
||||
"PREFIX hr: <https://example.com/hr/>\n",
|
||||
"\n",
|
||||
"SELECT ?personName ?roleTitle ?salary ?startDate\n",
|
||||
"WHERE {\n",
|
||||
" ?event a hr:EmploymentEvent ;\n",
|
||||
" hr:employee ?person ;\n",
|
||||
" hr:role ?role ;\n",
|
||||
" hr:salary ?salary ;\n",
|
||||
" hr:startDate ?startDate .\n",
|
||||
" ?person hr:name ?personName .\n",
|
||||
" ?role hr:title ?roleTitle .\n",
|
||||
"}\n",
|
||||
"ORDER BY DESC(?salary)\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"### Future: SPARQL 1.2 reifier syntax\n",
|
||||
"\n",
|
||||
"The SPARQL 1.2 draft introduces annotation syntax that lets you attach context directly to triples, without a separate intermediate node.\n",
|
||||
"Once the spec is ratified Semantica will adopt it, and the contextual query above may be expressible more concisely."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-15",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 6 (Optional): Load to Triplet Store and Run SPARQL\n",
|
||||
"\n",
|
||||
"Set `STORE_TO_TRIPLET=true` to load the KG into a live triplet store and run the contextual reification query."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-16",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if os.getenv(\"STORE_TO_TRIPLET\", \"false\").lower() == \"true\":\n",
|
||||
" store = TripletStore(\n",
|
||||
" backend=os.getenv(\"TRIPLET_BACKEND\", \"blazegraph\"),\n",
|
||||
" endpoint=os.getenv(\"TRIPLET_ENDPOINT\", \"http://localhost:9999/blazegraph\"),\n",
|
||||
" namespace=os.getenv(\"TRIPLET_NAMESPACE\", \"kb\"),\n",
|
||||
" )\n",
|
||||
" store_result = store.store(knowledge_graph=kg, ontology=ontology)\n",
|
||||
" print(\"Store result:\", store_result)\n",
|
||||
"\n",
|
||||
" # Contextual reification query — person + role + salary via EmploymentEvent\n",
|
||||
" query = \"\"\"\n",
|
||||
" PREFIX hr: <https://example.com/hr/>\n",
|
||||
"\n",
|
||||
" SELECT ?personName ?roleTitle ?salary ?startDate\n",
|
||||
" WHERE {\n",
|
||||
" ?event a hr:EmploymentEvent ;\n",
|
||||
" hr:employee ?person ;\n",
|
||||
" hr:role ?role ;\n",
|
||||
" hr:salary ?salary ;\n",
|
||||
" hr:startDate ?startDate .\n",
|
||||
" ?person hr:name ?personName .\n",
|
||||
" ?role hr:title ?roleTitle .\n",
|
||||
" }\n",
|
||||
" ORDER BY DESC(?salary)\n",
|
||||
" LIMIT 10\n",
|
||||
" \"\"\"\n",
|
||||
" result = store.execute_query(query)\n",
|
||||
" print(result)\n",
|
||||
"else:\n",
|
||||
" print(\"Skipping triplet-store load/query (set STORE_TO_TRIPLET=true to enable)\")"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -44,6 +44,7 @@ from semantica.context import AgentContext, ContextGraph
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
context = AgentContext(
|
||||
vector_store=VectorStore(backend="faiss", dimension=768),
|
||||
vector_store=VectorStore(backend="inmemory"),
|
||||
knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
decision_tracking=True,
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.8+-blue.svg" alt="Python 3.8+"></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
||||
<a href="https://pypi.org/project/semantica/"><img src="https://img.shields.io/pypi/v/semantica.svg" alt="PyPI"></a>
|
||||
<a href="https://github.com/Hawksight-AI/semantica/releases/tag/v0.3.0"><img src="https://img.shields.io/badge/version-0.3.0-brightgreen.svg" alt="Version"></a>
|
||||
<a href="https://github.com/Hawksight-AI/semantica/releases/tag/v0.4.0"><img src="https://img.shields.io/badge/version-0.4.0-brightgreen.svg" alt="Version"></a>
|
||||
<a href="https://pepy.tech/project/semantica"><img src="https://static.pepy.tech/badge/semantica" alt="Total Downloads"></a>
|
||||
<a href="https://github.com/Hawksight-AI/semantica/actions"><img src="https://github.com/Hawksight-AI/semantica/workflows/CI/badge.svg" alt="CI"></a>
|
||||
<a href="https://discord.gg/sV34vps5hH"><img src="https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
@@ -65,7 +65,7 @@ from semantica.context import AgentContext, ContextGraph
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
context = AgentContext(
|
||||
vector_store=VectorStore(backend="inmemory"),
|
||||
vector_store=VectorStore(backend="faiss", dimension=768),
|
||||
knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
decision_tracking=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Semantica Plugins (Community Guide)
|
||||
|
||||
Semantica ships a shared plugin bundle under `plugins/` with skills, agents, and hooks for knowledge graphs, context graphs, decision intelligence, reasoning, explainability, provenance, ontology, and export workflows.
|
||||
|
||||
This README is for community users who want to install or reuse the plugin package across Claude, Cursor, and Codex.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- Claude Code
|
||||
- Cursor
|
||||
- Codex
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Hawksight-AI/semantica.git
|
||||
cd semantica
|
||||
```
|
||||
|
||||
2. Ensure the plugin bundle exists at:
|
||||
|
||||
```text
|
||||
plugins/
|
||||
skills/
|
||||
agents/
|
||||
hooks/
|
||||
.claude-plugin/
|
||||
.cursor-plugin/
|
||||
.codex-plugin/
|
||||
```
|
||||
|
||||
## Plugin Contents
|
||||
|
||||
- `skills/`: 17 domain skills (`causal`, `decision`, `explain`, `reason`, `temporal`, etc.)
|
||||
- `agents/`: specialized agents (`decision-advisor`, `explainability`, `kg-assistant`)
|
||||
- `hooks/hooks.json`: plugin hook configuration
|
||||
- `.claude-plugin/plugin.json`: Claude manifest
|
||||
- `.cursor-plugin/plugin.json`: Cursor manifest
|
||||
- `.codex-plugin/plugin.json`: Codex manifest
|
||||
- `*/marketplace.json`: local marketplace definitions
|
||||
|
||||
## Install and Use in Claude Code
|
||||
|
||||
### Local install (fastest)
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
claude --plugin-dir ./plugins
|
||||
```
|
||||
|
||||
If your Claude setup uses plugin commands in-session, use:
|
||||
|
||||
```bash
|
||||
/plugin install ./plugins
|
||||
```
|
||||
|
||||
### Install from a GitHub marketplace
|
||||
|
||||
Add a marketplace hosted in git:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add <owner>/semantica
|
||||
```
|
||||
|
||||
Install Semantica from that marketplace:
|
||||
|
||||
```bash
|
||||
/plugin install semantica@<marketplace-name>
|
||||
```
|
||||
|
||||
### Verify in Claude
|
||||
|
||||
Run one of these in chat:
|
||||
|
||||
```text
|
||||
/semantica:decision list
|
||||
/semantica:explain decision <decision_id>
|
||||
```
|
||||
|
||||
If the plugin is installed correctly, Claude should recognize the `/semantica:*` skills.
|
||||
|
||||
## Install and Use in Codex
|
||||
|
||||
1. Ensure your repo marketplace exists at `.agents/plugins/marketplace.json`.
|
||||
2. Point the plugin entry `source.path` to `./plugins` (or your chosen plugin directory).
|
||||
3. Restart Codex and install from the marketplace UI.
|
||||
|
||||
Codex manifest used by this bundle:
|
||||
|
||||
- `.codex-plugin/plugin.json`
|
||||
|
||||
### Verify in Codex
|
||||
|
||||
After install, run a Semantica skill command in chat, for example:
|
||||
|
||||
```text
|
||||
/semantica:causal chain --subject <decision_id> --depth 3
|
||||
```
|
||||
|
||||
## Install and Use in Cursor
|
||||
|
||||
Cursor reads plugin metadata from:
|
||||
|
||||
- `.cursor-plugin/plugin.json`
|
||||
- `.cursor-plugin/marketplace.json`
|
||||
|
||||
If you maintain a team/community plugin repo, publish this `plugins/` directory and refresh/reinstall in Cursor Marketplace to pick up updates.
|
||||
|
||||
### Verify in Cursor
|
||||
|
||||
Try one of these commands:
|
||||
|
||||
```text
|
||||
/semantica:reason deductive "IF Person(x) THEN Mortal(x)"
|
||||
/semantica:visualize topology
|
||||
```
|
||||
|
||||
## First Commands to Try
|
||||
|
||||
After installing on any platform, these are good smoke tests:
|
||||
|
||||
1. `/semantica:decision record <category> "<scenario>" "<reasoning>" <outcome> <confidence>`
|
||||
2. `/semantica:decision list`
|
||||
3. `/semantica:causal chain --subject <decision_id> --depth 3`
|
||||
4. `/semantica:explain decision <decision_id>`
|
||||
5. `/semantica:validate graph`
|
||||
|
||||
## Community Notes
|
||||
|
||||
- Keep plugin name/version/keywords updated in each manifest before publishing.
|
||||
- Keep skill frontmatter consistent (`name` + `description`) for reliable discovery.
|
||||
- For open-source sharing, include this folder as-is so skills, agents, and hooks remain bundled.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "semantica-local",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "semantica",
|
||||
"description": "Semantica plugin for Claude: knowledge graph skills, reasoning, extraction, and visualization.",
|
||||
"source": "./",
|
||||
"category": "Productivity",
|
||||
"tags": [
|
||||
"knowledge-graph",
|
||||
"reasoning",
|
||||
"semantica"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "semantica",
|
||||
"description": "Full-stack knowledge graph skills: semantic extraction, decision intelligence, context graphs, reasoning, explainability, ontology, provenance, deduplication, visualization, and multi-format export.",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "Semantica Contributors"
|
||||
},
|
||||
"homepage": "https://github.com/Hawksight-AI/semantica",
|
||||
"repository": "https://github.com/Hawksight-AI/semantica",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"semantica",
|
||||
"knowledge graph",
|
||||
"context graphs",
|
||||
"decision intelligence",
|
||||
"explainability",
|
||||
"causal analysis",
|
||||
"provenance",
|
||||
"ontology",
|
||||
"graph analytics",
|
||||
"semantic extraction",
|
||||
"visualization",
|
||||
"reasoning",
|
||||
"extraction",
|
||||
"mcp"
|
||||
],
|
||||
"skills": "./skills",
|
||||
"agents": "./agents",
|
||||
"hooks": "./hooks/hooks.json"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "semantica-local",
|
||||
"interface": {
|
||||
"displayName": "Semantica Local Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "semantica-codex",
|
||||
"description": "Semantica plugin for Codex: knowledge graph commands and analytics.",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "semantica-codex",
|
||||
"description": "Semantica plugin for Codex: knowledge graph commands, export capabilities, and reasoning workflows.",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "Semantica Contributors"
|
||||
},
|
||||
"homepage": "https://github.com/Hawksight-AI/semantica",
|
||||
"repository": "https://github.com/Hawksight-AI/semantica",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"semantica",
|
||||
"knowledge graph",
|
||||
"codex",
|
||||
"context graphs",
|
||||
"decision intelligence",
|
||||
"explainability",
|
||||
"causal analysis",
|
||||
"provenance",
|
||||
"ontology",
|
||||
"graph analytics",
|
||||
"semantic extraction",
|
||||
"visualization",
|
||||
"reasoning",
|
||||
"extraction",
|
||||
"mcp"
|
||||
],
|
||||
"skills": "./skills",
|
||||
"interface": {
|
||||
"displayName": "Semantica Codex Plugin",
|
||||
"shortDescription": "Knowledge graph skills for Semantica workflows",
|
||||
"category": "Productivity",
|
||||
"developerName": "Semantica Contributors"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "semantica-local",
|
||||
"owner": {
|
||||
"name": "Semantica Contributors"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Semantica plugin marketplace for Cursor.",
|
||||
"version": "0.1.0",
|
||||
"pluginRoot": "."
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "semantica-cursor",
|
||||
"description": "Semantica plugin for Cursor: knowledge graph skills and analytics.",
|
||||
"source": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "semantica-cursor",
|
||||
"displayName": "Semantica Cursor Plugin",
|
||||
"description": "Semantica plugin for Cursor: knowledge graph skills, reasoning, extraction, and visualization.",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "Semantica Contributors"
|
||||
},
|
||||
"homepage": "https://github.com/Hawksight-AI/semantica",
|
||||
"repository": "https://github.com/Hawksight-AI/semantica",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"semantica",
|
||||
"knowledge graph",
|
||||
"cursor",
|
||||
"context graphs",
|
||||
"decision intelligence",
|
||||
"explainability",
|
||||
"causal analysis",
|
||||
"provenance",
|
||||
"ontology",
|
||||
"graph analytics",
|
||||
"semantic extraction",
|
||||
"visualization",
|
||||
"reasoning",
|
||||
"extraction",
|
||||
"mcp"
|
||||
],
|
||||
"skills": "./skills",
|
||||
"agents": "./agents",
|
||||
"hooks": "./hooks/hooks.json"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: decision-advisor
|
||||
description: Decision intelligence and causal reasoning specialist for Semantica. Proactively surfaces causal chains, precedent matches, policy violations, and influence scores when reviewing or recording decisions. Use for decision recording, precedent search, causal analysis, policy governance, and decision explainability workflows.
|
||||
---
|
||||
|
||||
You are a **Decision Intelligence Specialist** for the Semantica library. You focus on the full decision lifecycle: recording, querying, precedent search, causal analysis, policy compliance, and explainability.
|
||||
|
||||
## Your Domain
|
||||
|
||||
### Recording Decisions
|
||||
```python
|
||||
from semantica.context import AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True)
|
||||
decision_id = ctx.record_decision(
|
||||
category="loan_approval",
|
||||
scenario="First-time homebuyer, income 80k",
|
||||
reasoning="Good credit score, low DTI ratio",
|
||||
outcome="approved",
|
||||
confidence=0.95,
|
||||
entities=["customer_123", "property_456"],
|
||||
decision_maker="underwriting_agent",
|
||||
valid_from="2025-01-01",
|
||||
valid_until="2026-01-01",
|
||||
)
|
||||
```
|
||||
|
||||
### Querying and Precedent Search
|
||||
```python
|
||||
# Natural language query with multi-hop reasoning
|
||||
decisions = ctx.query_decisions(query, max_hops=3, use_hybrid_search=True)
|
||||
|
||||
# Hybrid precedent search — semantic + structural + vector
|
||||
precedents = ctx.find_precedents(scenario, category, limit=10, use_hybrid_search=True)
|
||||
|
||||
# Advanced KG-enhanced search
|
||||
advanced = ctx.find_precedents_advanced(
|
||||
scenario, use_kg_features=True,
|
||||
similarity_weights={"semantic": 0.5, "structural": 0.3, "vector": 0.2}
|
||||
)
|
||||
|
||||
# Category/entity/time filters via DecisionQuery
|
||||
from semantica.context.decision_query import DecisionQuery
|
||||
dq = DecisionQuery(graph_store=ctx.graph_store)
|
||||
by_cat = dq.find_by_category(category, limit=100)
|
||||
by_ent = dq.find_by_entity(entity_id, limit=100)
|
||||
by_time = dq.find_by_time_range(start, end, limit=100)
|
||||
multi_hop = dq.multi_hop_reasoning(start_entity, query_context, max_hops=3)
|
||||
```
|
||||
|
||||
### Causal Analysis
|
||||
```python
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
|
||||
analyzer = CausalChainAnalyzer(graph_store=ctx.graph_store)
|
||||
|
||||
# Upstream (what caused this?) or downstream (what did this cause?)
|
||||
chain = analyzer.get_causal_chain(decision_id, direction="upstream", max_depth=10)
|
||||
|
||||
# Root causes
|
||||
roots = analyzer.find_root_causes(decision_id)
|
||||
|
||||
# Downstream impact
|
||||
influenced = analyzer.get_influenced_decisions(decision_id)
|
||||
score = analyzer.get_causal_impact_score(decision_id)
|
||||
|
||||
# Full network analysis
|
||||
network = analyzer.analyze_causal_network()
|
||||
loops = analyzer.find_causal_loops()
|
||||
|
||||
# Historical chain at a specific time
|
||||
historical = analyzer.trace_at_time(decision_id, at_time="2024-06-01", direction="upstream")
|
||||
```
|
||||
|
||||
### Policy Compliance
|
||||
```python
|
||||
from semantica.context import AgentContext
|
||||
|
||||
engine = ctx.get_policy_engine()
|
||||
|
||||
# Check compliance
|
||||
compliant = engine.check_compliance(decision, policy_id)
|
||||
|
||||
# Get all applicable policies
|
||||
applicable = engine.get_applicable_policies(category, entities)
|
||||
|
||||
# Analyze impact of policy changes
|
||||
impact = engine.analyze_policy_impact(policy_id, proposed_rules)
|
||||
|
||||
# Record exceptions
|
||||
exception_id = engine.record_exception(decision_id, policy_id, reason, approver, justification)
|
||||
```
|
||||
|
||||
### Explainability
|
||||
```python
|
||||
# Full explainability trace
|
||||
explainability = ctx.trace_decision_explainability(decision_id)
|
||||
|
||||
# Influence analysis with KG algorithms
|
||||
influence = ctx.analyze_decision_influence(decision_id, max_depth=3)
|
||||
predictions = ctx.predict_decision_relationships(decision_id, top_k=5)
|
||||
```
|
||||
|
||||
## Critical Invariants
|
||||
|
||||
- **Node type duality**: `record_decision()` → `"decision"` (lowercase); `add_decision()` → `"Decision"` (capitalized). Always search for both when querying.
|
||||
- **No `DecisionQuery.query()`** — use `find_by_entity`, `find_by_category`, `find_by_time_range`, or `multi_hop_reasoning`.
|
||||
- **`CausalChainAnalyzer` takes `graph_store=`** — no `trace_causes()`, use `get_causal_chain(direction="upstream")`.
|
||||
- **`find_precedents(as_of=<date>)`** — supports temporal precedent search.
|
||||
- **`graph_store` format** — both `DecisionQuery` and `CausalChainAnalyzer` need `{"records": [...]}` shape.
|
||||
|
||||
## Behavior
|
||||
|
||||
When a user shares a decision or asks about decision-making, **proactively**:
|
||||
1. **Trace root causes** via `get_causal_chain(direction="upstream")`
|
||||
2. **Check policy compliance** via `get_applicable_policies()` + `check_compliance()`
|
||||
3. **Find precedents** via `find_precedents_advanced(use_kg_features=True)`
|
||||
4. **Score influence** via `get_causal_impact_score()`
|
||||
5. **Detect loops** — flag if this decision closes a causal loop
|
||||
|
||||
When reviewing Semantica decision code:
|
||||
- Check method names against the list above
|
||||
- Flag queries that only check one of `"decision"` / `"Decision"`
|
||||
- Flag missing `entities=[]` arg (defaults to None, may miss entity-based precedent search)
|
||||
|
||||
Show causal chains as Mermaid `graph TD` blocks. Keep tables concise. Lead with decision status and compliance, then causal context, then influence score.
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: explainability
|
||||
description: Reasoning transparency and auditability specialist for Semantica. Answers "why does the graph believe X?", "how was Y inferred?", and "is this decision explainable?" with full evidence chains. Produces audit-ready explanation reports using ExplanationGenerator, AgentContext.trace_decision_explainability, and ContextGraph.trace_decision_chain.
|
||||
---
|
||||
|
||||
You are a **Reasoning Transparency and Explainability Specialist** for the Semantica library. You answer "why?" questions about graph facts, inferences, and decisions with complete, auditable evidence chains.
|
||||
|
||||
## Your Domain
|
||||
|
||||
### Explanation Generation
|
||||
```python
|
||||
from semantica.reasoning.explanation_generator import ExplanationGenerator
|
||||
|
||||
gen = ExplanationGenerator()
|
||||
|
||||
# generate_explanation(reasoning) → Explanation object
|
||||
# reasoning can be any reasoning object, dict, or string context
|
||||
explanation = gen.generate_explanation(reasoning=reasoning_input)
|
||||
# explanation.summary, .confidence, .evidence
|
||||
|
||||
# show_reasoning_path(reasoning) → ReasoningPath object
|
||||
path = gen.show_reasoning_path(reasoning=reasoning_input)
|
||||
# path.steps: [Step(type, description, confidence)]
|
||||
# path.conclusion
|
||||
|
||||
# justify_conclusion(conclusion, reasoning_path) → Justification object
|
||||
justification = gen.justify_conclusion(
|
||||
conclusion=conclusion,
|
||||
reasoning_path=path,
|
||||
)
|
||||
# justification.is_justified, .confidence, .supporting_steps, .opposing_factors
|
||||
```
|
||||
|
||||
### Decision Explainability
|
||||
```python
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
|
||||
ctx = AgentContext(decision_tracking=True, advanced_analytics=True)
|
||||
|
||||
# Full decision explainability trace
|
||||
explainability = ctx.trace_decision_explainability(decision_id)
|
||||
# Returns: reasoning_steps, evidence, causal_context, compliance_status
|
||||
|
||||
# Causal chain from ContextGraph
|
||||
graph = ContextGraph(advanced_analytics=True)
|
||||
chain = graph.trace_decision_chain(decision_id, max_steps=5)
|
||||
causality = graph.trace_decision_causality(decision_id, max_depth=5)
|
||||
|
||||
# Influence analysis
|
||||
influence = ctx.analyze_decision_influence(decision_id, max_depth=3)
|
||||
```
|
||||
|
||||
### Provenance Tracing
|
||||
```python
|
||||
from semantica.kg.kg_provenance import GraphBuilderWithProvenance
|
||||
from semantica.context.context_provenance import ContextManagerWithProvenance
|
||||
from semantica.reasoning.reasoning_provenance import ReasoningEngineWithProvenance
|
||||
from semantica.semantic_extract.semantic_extract_provenance import (
|
||||
NERExtractorWithProvenance,
|
||||
RelationExtractorWithProvenance,
|
||||
EventDetectorWithProvenance,
|
||||
)
|
||||
```
|
||||
|
||||
Each provenance-enabled class wraps the base class and adds `.get_provenance_summary()` to retrieve lineage records.
|
||||
|
||||
### Reasoning Chains
|
||||
```python
|
||||
from semantica.reasoning.deductive_reasoner import DeductiveReasoner
|
||||
|
||||
reasoner = DeductiveReasoner()
|
||||
proof = reasoner.prove_theorem(theorem)
|
||||
# proof.steps, proof.is_valid, proof.confidence
|
||||
|
||||
validation = reasoner.validate_argument(argument)
|
||||
```
|
||||
|
||||
## Explanation Types You Produce
|
||||
|
||||
**1. Decision explanations** — full trace: reasoning steps → causal antecedents → policy compliance → evidence
|
||||
**2. Reasoning path explanations** — step-by-step rule chain with variable bindings
|
||||
**3. Conclusion justifications** — why a conclusion follows from premises, with opposing factors noted
|
||||
**4. Path explanations** — how two nodes are semantically connected via the graph
|
||||
**5. Compliance explanations** — which rules passed/failed and why, with remediation advice
|
||||
|
||||
## Audit Report Format
|
||||
|
||||
When asked for an audit report:
|
||||
```
|
||||
Explainability Audit Report
|
||||
════════════════════════════
|
||||
Generated: <ISO timestamp>
|
||||
Scope: <N decisions / K facts>
|
||||
|
||||
── Decision Explanations ─────────────────
|
||||
Decision <id>: EXPLAINED ✓ (confidence: 0.91)
|
||||
Steps: 3 | Evidence: 2 items | Provenance: complete
|
||||
Causal antecedents: <n>
|
||||
Policy compliance: 2/2 ✓
|
||||
|
||||
Decision <id>: PARTIALLY EXPLAINED ⚠
|
||||
Missing: provenance gap on reasoning step 2
|
||||
Low confidence: 0.43 on step 3
|
||||
|
||||
── Summary ──────────────────────────────
|
||||
Total: N decisions analyzed
|
||||
Fully explained: M (X%)
|
||||
Partially explained: K (Y%)
|
||||
Unexplained (gaps): J (Z%)
|
||||
|
||||
Provenance gaps: J nodes missing lineage
|
||||
Low-confidence facts (<0.7): L
|
||||
Circular reasoning detected: YES / NO
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
When asked "why does the graph believe X?":
|
||||
1. Start with `ExplanationGenerator.generate_explanation()` for the natural-language summary
|
||||
2. Supplement with `show_reasoning_path()` for the step trace
|
||||
3. Cross-check with provenance wrappers for source lineage
|
||||
4. Flag any provenance gaps
|
||||
|
||||
When a decision explanation is requested:
|
||||
1. Always call `ctx.trace_decision_explainability(decision_id)` first
|
||||
2. Then supplement with `trace_decision_chain()` and `trace_decision_causality()`
|
||||
3. Check policy compliance via `get_applicable_policies()` + `check_compliance()`
|
||||
|
||||
Lead with the direct answer, then the evidence chain. Use Mermaid `sequenceDiagram` for multi-step reasoning chains. Use nested bullets for evidence items.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: kg-assistant
|
||||
description: General-purpose KG-aware assistant for any Semantica task. Knows all module APIs, exact method signatures, node-type conventions, and current graph schema. Use for broad questions, multi-module workflows, code review, or any task spanning multiple Semantica modules.
|
||||
---
|
||||
|
||||
You are a knowledge graph expert assistant for the **Semantica** library — a full-stack Python library for knowledge graphs, semantic extraction, decision intelligence, reasoning, and context management.
|
||||
|
||||
## Module Overview
|
||||
|
||||
### Decision Intelligence (semantica.context)
|
||||
- `AgentContext` — high-level interface: `store()`, `retrieve()`, `record_decision()`, `query_decisions()`, `find_precedents()`, `find_precedents_advanced()`, `analyze_decision_influence()`, `predict_decision_relationships()`, `trace_decision_explainability()`, `get_context_insights()`, `multi_hop_context_query()`, `expand_query()`, `query_with_reasoning()`, `get_causal_chain()`, `capture_cross_system_inputs()`, `get_policy_engine()`
|
||||
- `ContextGraph` — in-memory graph: `add_node()`, `add_edge()`, `record_decision()`, `find_precedents_by_scenario()`, `find_similar_decisions()`, `analyze_decision_influence()`, `analyze_decision_impact()`, `get_causal_chain()`, `trace_decision_causality()`, `trace_decision_chain()`, `enforce_decision_policy()`, `check_decision_rules()`, `get_decision_insights()`, `get_decision_summary()`, `analyze_graph_with_kg()`, `get_node_centrality()`, `get_node_importance()`, `state_at()`, `query()`
|
||||
- `DecisionQuery` — `find_by_category()`, `find_by_entity()`, `find_by_time_range()`, `find_precedents_hybrid()`, `find_similar_exceptions()`, `multi_hop_reasoning()`, `predict_decision_relationships()`, `analyze_decision_influence()`, `trace_decision_path()`
|
||||
- `CausalChainAnalyzer` — `get_causal_chain(decision_id, direction, max_depth)`, `find_root_causes()`, `get_influenced_decisions()`, `get_causal_impact_score()`, `get_precedent_chain()`, `analyze_causal_network()`, `find_causal_loops()`, `trace_at_time(event_id, at_time, direction, max_depth)`
|
||||
- `PolicyEngine` — `add_policy()`, `check_compliance()`, `get_applicable_policies()`, `update_policy()`, `record_exception()`, `analyze_policy_impact()`, `get_affected_decisions()`, `get_policy_history()`
|
||||
- `DecisionRecorder` — `record_decision()`, `link_entities()`, `link_precedents()`, `apply_policies()`, `record_exception()`, `capture_cross_system_context()`, `record_approval_chain()`
|
||||
|
||||
### Knowledge Graph (semantica.kg)
|
||||
- `GraphAnalyzer` — `analyze_graph()`, `calculate_centrality(graph, centrality_type)`, `detect_communities(graph, algorithm)`, `analyze_temporal_evolution()`, `compute_metrics()`, `analyze_connectivity()`
|
||||
- `CentralityCalculator` — `calculate_degree_centrality()`, `calculate_betweenness_centrality()`, `calculate_closeness_centrality()`, `calculate_eigenvector_centrality()`, `calculate_pagerank()`, `calculate_all_centrality()`
|
||||
- `CommunityDetector` — `detect_communities()`, `detect_communities_louvain()`, `detect_communities_leiden()`, `detect_communities_label_propagation()`, `detect_overlapping_communities()`, `analyze_community_structure()`, `calculate_community_metrics()`
|
||||
- `NodeEmbedder` — `compute_embeddings(graph_store, node_labels, relationship_types)`, `find_similar_nodes(graph_store, node_id, top_k)`, `store_embeddings()`
|
||||
- `SimilarityCalculator` — `cosine_similarity(vector1, vector2)`, `euclidean_distance()`, `manhattan_distance()`, `correlation_similarity()`, `find_most_similar()`, `batch_similarity()`, `pairwise_similarity()`
|
||||
- `LinkPredictor` — `score_link(graph_store, node_id1, node_id2, method=)`, `predict_top_links()`, `predict_links()`, `batch_score_links()`
|
||||
- `PathFinder` — `find_k_shortest_paths()`, `dijkstra_shortest_path()`, `bfs_shortest_path()`, `a_star_search()`, `all_shortest_paths()`, `path_length()`
|
||||
|
||||
### Reasoning (semantica.reasoning)
|
||||
- `DeductiveReasoner` — `add_facts()`, `apply_logic(premises)`, `prove_theorem()`, `validate_argument()`
|
||||
- `AbductiveReasoner` — `add_knowledge()`, `generate_hypotheses(observations)`, `find_explanations()`, `get_best_explanation()`, `rank_hypotheses()`
|
||||
- `ExplanationGenerator` — `generate_explanation(reasoning)`, `show_reasoning_path(reasoning)`, `justify_conclusion(conclusion, reasoning_path)`
|
||||
|
||||
### Extraction (semantica.semantic_extract)
|
||||
- `NamedEntityRecognizer`, `RelationExtractor`, `EventDetector`, `CoreferenceResolver`, `TripletExtractor`, `ExtractionValidator`
|
||||
- **Always** call `_result_cache.clear()` before any extraction run
|
||||
|
||||
### Pipeline (semantica.pipeline)
|
||||
- `PipelineBuilder` — `add_step()`, `connect_steps()`, `validate_pipeline()`, `build()`
|
||||
- `PipelineValidator` — `validate(pipeline)` → `ValidationResult(valid, errors, warnings)` — **does NOT raise**
|
||||
- `FailureHandler` — `handle_failure(error, policy, retry_count)` → `RecoveryAction`
|
||||
|
||||
### Export (semantica.export)
|
||||
- `RDFExporter.export_to_rdf(data, format='turtle')` → **returns a string**, no `output_path`
|
||||
- Format aliases: `"ttl"` → `"turtle"`, `"nt"`, `"xml"`, `"json-ld"`
|
||||
- Other exporters: `OWLExporter`, `CSVExporter`, `JSONExporter`, `ParquetExporter`, `ArrowExporter`, `VectorExporter`, `YAMLSchemaExporter`, `ArangoAQLExporter`, `LPGExporter`, `ReportGenerator`
|
||||
|
||||
### Deduplication (semantica.deduplication)
|
||||
- `DuplicateDetector.detect_duplicates(entities, threshold=)` — use **directly**, never via `methods.py` (infinite recursion bug)
|
||||
|
||||
## Critical API Invariants
|
||||
|
||||
| Area | Correct |
|
||||
|------|---------|
|
||||
| Decision node type | `record_decision()` → stored as `"decision"` (lowercase); `add_decision()` → `"Decision"` (capitalized). Query both. |
|
||||
| `AgentContext.record_decision` | Returns a `decision_id: str`. Args: `category, scenario, reasoning, outcome, confidence, entities, decision_maker, valid_from, valid_until` |
|
||||
| `CausalChainAnalyzer` | Takes `graph_store=` kwarg. No `trace_causes()` — use `get_causal_chain(direction="upstream")` |
|
||||
| `ExplanationGenerator` | No `explain_decision/fact/inference` — use `generate_explanation(reasoning)`, `show_reasoning_path(reasoning)`, `justify_conclusion(conclusion, path)` |
|
||||
| `DecisionQuery` | No `.query()` — use `find_by_entity`, `find_by_category`, `find_by_time_range`, `multi_hop_reasoning` |
|
||||
| `SimilarityCalculator` | `cosine_similarity(vector1, vector2)` — two required positional args |
|
||||
| `NodeEmbedder` | `compute_embeddings(graph_store, node_labels, relationship_types)` — all three positional, all required |
|
||||
| `LinkPredictor` | `score_link(graph_store, node_id1, node_id2, method=)` |
|
||||
| `PipelineValidator` | `validate(pipeline)` returns `ValidationResult` — never raises |
|
||||
| `RDFExporter` | `export_to_rdf(data, format='turtle')` returns a string |
|
||||
| Cache | `_result_cache.clear()` before every extraction |
|
||||
| Graph store format | `DecisionQuery` and `CausalChainAnalyzer` need `{"records": [...]}` from graph store |
|
||||
|
||||
## How to Help
|
||||
|
||||
1. **Answer questions** with copy-paste-ready code that uses the correct method names
|
||||
2. **Review Semantica code** — check against the invariants table above before suggesting anything
|
||||
3. **Suggest the right skill** — map user intent to `/semantica:*` skills
|
||||
4. **Debug errors** — common mistakes: wrong method name, wrong arg order, missing `_result_cache.clear()`, querying only one of `"decision"`/`"Decision"` types
|
||||
|
||||
Keep responses code-first. Show the full import path in every example.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{"matcher": "Write|Edit", "hooks": [{"type": "command", "command": "FILE=$(jq -r .tool_input.file_path 2>/dev/null); if echo $FILE | grep -qE semantica/; then python -c 'import ast,sys; ast.parse(open(sys.argv[1]).read())' $FILE 2>&1; fi"}]},
|
||||
|
||||
{"matcher": "Write|Edit", "hooks": [{"type": "command", "command": "echo PostToolUse provenance check"}]}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{"matcher": "Bash", "hooks": [{"type": "command", "command": "CMD=$(jq -r .tool_input.command 2>/dev/null); if echo $CMD | grep -q deduplication/methods; then echo WARNING: use DuplicateDetector directly >&2; fi"}]}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: causal
|
||||
description: Analyze cause-and-effect relationships in the Semantica knowledge graph — causal chains, interventions, counterfactuals, and causal influence scores.
|
||||
---
|
||||
|
||||
# /semantica:causal
|
||||
|
||||
Analyze causal relationships and infer impacts. Usage: `/semantica:causal <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = task + optional target entity, filter, or intervention.
|
||||
|
||||
---
|
||||
|
||||
## `chain [--subject <node>] [--depth N]`
|
||||
|
||||
Build and inspect causal chains for a subject or category.
|
||||
|
||||
```python
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
from semantica.context import AgentContext
|
||||
|
||||
# Option 1: Use an existing AgentContext decision backend
|
||||
chain = ctx.get_causal_chain(
|
||||
decision_id=decision_id,
|
||||
direction="upstream",
|
||||
max_depth=depth,
|
||||
)
|
||||
|
||||
# Option 2: Use CausalChainAnalyzer directly
|
||||
analyzer = CausalChainAnalyzer(graph_store=ctx.knowledge_graph)
|
||||
downstream = analyzer.get_causal_chain(
|
||||
decision_id=decision_id,
|
||||
direction="downstream",
|
||||
max_depth=depth,
|
||||
)
|
||||
```
|
||||
|
||||
Output: chain steps, cause strength, effect reach, and summary graph.
|
||||
|
||||
---
|
||||
|
||||
## `intervene <node> <action> [--scenario <json>]`
|
||||
|
||||
Analyze decision impact and influenced decisions (current causal API).
|
||||
|
||||
```python
|
||||
analyzer = CausalChainAnalyzer(graph_store=ctx.knowledge_graph)
|
||||
impact_score = analyzer.get_causal_impact_score(decision_id=decision_id)
|
||||
influenced = analyzer.get_influenced_decisions(
|
||||
decision_id=decision_id,
|
||||
max_depth=depth,
|
||||
)
|
||||
```
|
||||
|
||||
Return: impact score, influenced decisions, and downstream scope.
|
||||
|
||||
---
|
||||
|
||||
## `counterfactual <fact> [--weight N]`
|
||||
|
||||
Trace root causes and temporal causal paths.
|
||||
|
||||
```python
|
||||
analyzer = CausalChainAnalyzer(graph_store=ctx.knowledge_graph)
|
||||
roots = analyzer.find_root_causes(decision_id=decision_id, max_depth=depth)
|
||||
historical_chain = analyzer.trace_at_time(
|
||||
event_id=decision_id,
|
||||
at_time="2026-01-01T00:00:00Z",
|
||||
direction="upstream",
|
||||
max_depth=depth,
|
||||
)
|
||||
```
|
||||
|
||||
Output: root decision lineage and time-bounded causal context.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: change
|
||||
description: Track and inspect graph changes, diffs, temporal updates, and the impact of new data on Semantica knowledge graphs.
|
||||
---
|
||||
|
||||
# /semantica:change
|
||||
|
||||
Inspect changes over time and evaluate updates. Usage: `/semantica:change <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = task + optional node, time window, or filter.
|
||||
|
||||
---
|
||||
|
||||
## `diff [--from <ts>] [--to <ts>] [--node <id>]`
|
||||
|
||||
Compute graph diffs between two snapshots.
|
||||
|
||||
```python
|
||||
from semantica.provenance.change_tracker import ChangeTracker
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
tracker = ChangeTracker()
|
||||
diff = tracker.compute_diff(from_ts=from_ts, to_ts=to_ts, node_id=node_id)
|
||||
```
|
||||
|
||||
Output: added/removed nodes and edges, attribute changes, and impact summary.
|
||||
|
||||
---
|
||||
|
||||
## `history <node_id> [--limit N]`
|
||||
|
||||
Show the change history for a node or relationship.
|
||||
|
||||
```python
|
||||
history = tracker.get_node_history(node_id=node_id, limit=limit)
|
||||
```
|
||||
|
||||
Return: revisions, timestamps, authors, and summary comments.
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: decision
|
||||
description: Full decision lifecycle in Semantica — record, query, find precedents (hybrid/advanced), analyze influence, explain, insights dashboard, list, and record exceptions. Uses AgentContext, ContextGraph, DecisionQuery, CausalChainAnalyzer, DecisionRecorder.
|
||||
---
|
||||
|
||||
# /semantica:decision
|
||||
|
||||
Full decision lifecycle management. Usage: `/semantica:decision <sub-command> [args]`
|
||||
|
||||
---
|
||||
|
||||
## `record <category> "<scenario>" "<reasoning>" <outcome> <confidence>`
|
||||
|
||||
Record a decision with full context.
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True)
|
||||
decision_id = ctx.record_decision(
|
||||
category=category, # "loan_approval", "deployment", "hiring"
|
||||
scenario=scenario, # natural-language situation description
|
||||
reasoning=reasoning, # why this decision was made
|
||||
outcome=outcome, # "approved", "rejected", "deferred"
|
||||
confidence=float(confidence),
|
||||
entities=entities or [],
|
||||
decision_maker="ai_agent",
|
||||
valid_from=valid_from, # optional ISO date string
|
||||
valid_until=valid_until,
|
||||
)
|
||||
```
|
||||
|
||||
Output: `Decision <decision_id> recorded | <category> | <outcome> (conf: 0.95)`
|
||||
|
||||
---
|
||||
|
||||
## `query "<question>" [--hops N] [--hybrid]`
|
||||
|
||||
Query decisions using natural language with multi-hop graph traversal.
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True, advanced_analytics=True)
|
||||
results = ctx.query_decisions(
|
||||
query=question,
|
||||
max_hops=int(hops) if hops else 3,
|
||||
include_context=True,
|
||||
use_hybrid_search="--hybrid" in args,
|
||||
)
|
||||
```
|
||||
|
||||
For structured lookups use `DecisionQuery`:
|
||||
```python
|
||||
from semantica.context.decision_query import DecisionQuery
|
||||
dq = DecisionQuery(graph_store=ctx.graph_store)
|
||||
# dq.find_by_category(category, limit=100)
|
||||
# dq.find_by_entity(entity_id, limit=100)
|
||||
# dq.find_by_time_range(start, end, limit=100)
|
||||
# dq.multi_hop_reasoning(start_entity, query_context, max_hops=3)
|
||||
# dq.trace_decision_path(decision_id, relationship_types)
|
||||
# dq.analyze_decision_influence(decision_id, max_depth=3)
|
||||
```
|
||||
|
||||
Return: `| ID | Category | Scenario | Outcome | Confidence | Timestamp |`
|
||||
|
||||
---
|
||||
|
||||
## `precedents "<scenario>" [--category <cat>] [--advanced] [--hops N] [--as-of <date>]`
|
||||
|
||||
Find similar past decisions using hybrid semantic + structural + vector search.
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True, kg_algorithms=True, vector_store_features=True)
|
||||
|
||||
if "--advanced" in args:
|
||||
precedents = ctx.find_precedents_advanced(
|
||||
scenario=scenario, category=category, limit=10,
|
||||
use_kg_features=True,
|
||||
similarity_weights={"semantic": 0.5, "structural": 0.3, "vector": 0.2},
|
||||
)
|
||||
else:
|
||||
precedents = ctx.find_precedents(
|
||||
scenario=scenario, category=category, limit=10,
|
||||
use_hybrid_search=True,
|
||||
max_hops=int(hops) if hops else 3,
|
||||
include_context=True,
|
||||
include_superseded=False,
|
||||
as_of=as_of_date or None, # temporal filter: only precedents that existed as_of this date
|
||||
)
|
||||
```
|
||||
|
||||
Return ranked: `| Rank | ID | Scenario | Outcome | Confidence | Similarity | Date |`
|
||||
|
||||
---
|
||||
|
||||
## `influence <decision_id> [--depth N]`
|
||||
|
||||
Analyze how a decision influences others across the graph.
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True, advanced_analytics=True, kg_algorithms=True)
|
||||
influence = ctx.analyze_decision_influence(decision_id, max_depth=int(depth) if depth else 3)
|
||||
predictions = ctx.predict_decision_relationships(decision_id, top_k=5)
|
||||
```
|
||||
|
||||
Output: Influence score + influenced decisions table + predicted new relationships.
|
||||
|
||||
---
|
||||
|
||||
## `explain <decision_id>`
|
||||
|
||||
Full explainability trace — reasoning steps, causal antecedents, policy compliance.
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
|
||||
ctx = AgentContext(decision_tracking=True)
|
||||
explainability = ctx.trace_decision_explainability(decision_id)
|
||||
|
||||
graph = ContextGraph(advanced_analytics=True)
|
||||
chain = graph.trace_decision_chain(decision_id, max_steps=5)
|
||||
causality = graph.trace_decision_causality(decision_id, max_depth=5)
|
||||
```
|
||||
|
||||
Output: Reasoning steps, causal antecedents, evidence items, policy compliance status.
|
||||
|
||||
---
|
||||
|
||||
## `insights`
|
||||
|
||||
Comprehensive analytics across all tracked decisions.
|
||||
|
||||
```python
|
||||
from semantica.context import ContextGraph, AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True, advanced_analytics=True)
|
||||
graph = ContextGraph(advanced_analytics=True)
|
||||
|
||||
insights = graph.get_decision_insights()
|
||||
summary = graph.get_decision_summary()
|
||||
context_insights = ctx.get_context_insights()
|
||||
```
|
||||
|
||||
Output: Total count, category breakdown, outcome distribution, avg confidence, top influential.
|
||||
|
||||
---
|
||||
|
||||
## `list [--category <cat>] [--entity <id>] [--from <date>] [--to <date>]`
|
||||
|
||||
```python
|
||||
from semantica.context.decision_query import DecisionQuery
|
||||
from semantica.context import AgentContext
|
||||
from datetime import datetime
|
||||
|
||||
ctx = AgentContext(decision_tracking=True)
|
||||
dq = DecisionQuery(graph_store=ctx.graph_store)
|
||||
|
||||
if category: decisions = dq.find_by_category(category, limit=100)
|
||||
elif entity: decisions = dq.find_by_entity(entity, limit=100)
|
||||
elif from_date: decisions = dq.find_by_time_range(
|
||||
start=datetime.fromisoformat(from_date),
|
||||
end=datetime.fromisoformat(to_date or "2099-12-31"),
|
||||
)
|
||||
```
|
||||
|
||||
Return: `| ID | Category | Scenario | Outcome | Confidence | Maker | Timestamp |`
|
||||
|
||||
---
|
||||
|
||||
## `exception <decision_id> <policy_id> "<reason>" --approver <name>`
|
||||
|
||||
Record a formal policy exception.
|
||||
|
||||
```python
|
||||
from semantica.context.decision_recorder import DecisionRecorder
|
||||
from semantica.context import AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True)
|
||||
recorder = DecisionRecorder(graph_store=ctx.graph_store)
|
||||
|
||||
exception_id = recorder.record_exception(
|
||||
decision_id=decision_id, policy_id=policy_id,
|
||||
reason=reason, approver=approver,
|
||||
approval_method="manual_override", justification=reason,
|
||||
)
|
||||
|
||||
from semantica.context.decision_query import DecisionQuery
|
||||
dq = DecisionQuery(graph_store=ctx.graph_store)
|
||||
similar = dq.find_similar_exceptions(exception_reason=reason, limit=5)
|
||||
```
|
||||
|
||||
Output: `Exception recorded: <exception_id>` + similar past exceptions for audit context.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: deduplicate
|
||||
description: Detect duplicate entities, duplicate groups, and relationship duplicates in Semantica using fuzzy matching, schema heuristics, and graph similarity.
|
||||
---
|
||||
|
||||
# /semantica:deduplicate
|
||||
|
||||
Remove duplicates from the knowledge graph. Usage: `/semantica:deduplicate <strategy> [args]`
|
||||
|
||||
`$ARGUMENTS` = deduplication strategy + optional entity or threshold.
|
||||
|
||||
---
|
||||
|
||||
## `entities [--threshold <score>] [--field <name>]`
|
||||
|
||||
Detect duplicate entities and group them by similarity.
|
||||
|
||||
```python
|
||||
from semantica.deduplication import DuplicateDetector
|
||||
|
||||
finder = DuplicateDetector()
|
||||
candidates = finder.detect_duplicates(entities, threshold=threshold)
|
||||
groups = finder.detect_duplicate_groups(entities, threshold=threshold)
|
||||
```
|
||||
|
||||
Output: duplicate candidate list, duplicate groups, and representative merge recommendations.
|
||||
|
||||
---
|
||||
|
||||
## `relations [--similarity <score>]`
|
||||
|
||||
Detect duplicate relationships and normalize edge representations.
|
||||
|
||||
```python
|
||||
from semantica.deduplication import DuplicateDetector
|
||||
|
||||
finder = DuplicateDetector()
|
||||
relations = finder.detect_duplicates(relation_list, threshold=similarity)
|
||||
```
|
||||
|
||||
Result: duplicate relation candidates, normalized relationship groups, and cleanup summary.
|
||||
@@ -0,0 +1,230 @@
|
||||
---
|
||||
name: embed
|
||||
description: Generate, inspect, and use node/text embeddings in Semantica — compute Node2Vec embeddings, find similar nodes, score link predictions, batch similarity, and pairwise similarity. Uses NodeEmbedder, SimilarityCalculator, LinkPredictor, and AgentContext. Sub-commands: compute, similar, similarity, predict-link, top-links, batch, pairwise.
|
||||
---
|
||||
|
||||
# /semantica:embed
|
||||
|
||||
Generate and inspect graph embeddings. Usage: `/semantica:embed <sub-command> [args]`
|
||||
|
||||
`$ARGUMENTS` = sub-command + arguments.
|
||||
|
||||
---
|
||||
|
||||
## `compute [--labels <t1,t2>] [--rels <r1,r2>] [--dim N] [--walks N]`
|
||||
|
||||
Generate Node2Vec embeddings for graph nodes.
|
||||
|
||||
```python
|
||||
from semantica.kg.node_embeddings import NodeEmbedder
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
embedder = NodeEmbedder()
|
||||
|
||||
node_labels = labels_arg.split(",") if labels_arg else graph.get_all_node_types()
|
||||
rel_types = rels_arg.split(",") if rels_arg else []
|
||||
|
||||
# All positional args required: graph_store, node_labels, relationship_types
|
||||
embeddings = embedder.compute_embeddings(
|
||||
graph_store=graph,
|
||||
node_labels=node_labels,
|
||||
relationship_types=rel_types,
|
||||
embedding_dimension=int(dim_arg) if dim_arg else None,
|
||||
num_walks=int(walks_arg) if walks_arg else None,
|
||||
)
|
||||
|
||||
# Store embeddings back on nodes
|
||||
embedder.store_embeddings(
|
||||
graph_store=graph,
|
||||
embeddings=embeddings,
|
||||
property_name="node2vec_embedding",
|
||||
)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Embeddings computed and stored.
|
||||
Nodes embedded: N
|
||||
Embedding dim: 128
|
||||
Node types covered: [type1, type2, ...]
|
||||
|
||||
Sample (first 5 nodes):
|
||||
| Node | Type | Embedding dim | Stored |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `similar <node_id> [--top N]`
|
||||
|
||||
Find the most similar nodes to a given node in embedding space.
|
||||
|
||||
```python
|
||||
from semantica.kg.node_embeddings import NodeEmbedder
|
||||
from semantica.context import ContextGraph, AgentContext
|
||||
|
||||
graph = ContextGraph()
|
||||
embedder = NodeEmbedder()
|
||||
|
||||
# NodeEmbedder.find_similar_nodes uses the stored node2vec_embedding property
|
||||
neighbors = embedder.find_similar_nodes(
|
||||
graph_store=graph,
|
||||
node_id=node_id,
|
||||
top_k=int(top_n) if top_n else 10,
|
||||
embedding_property="node2vec_embedding",
|
||||
)
|
||||
|
||||
# Also use AgentContext for richer similarity with metadata
|
||||
ctx = AgentContext(kg_algorithms=True)
|
||||
entity_similar = ctx.find_similar_entities(
|
||||
entity_id=node_id,
|
||||
similarity_type="content", # or "structural", "hybrid"
|
||||
top_k=int(top_n) if top_n else 10,
|
||||
)
|
||||
```
|
||||
|
||||
Return: `| Rank | Node ID | Type | Cosine Similarity | Shared Properties |`
|
||||
|
||||
---
|
||||
|
||||
## `similarity <n1> <n2> [--method cosine|euclidean|manhattan|correlation]`
|
||||
|
||||
Compute pairwise similarity between two nodes.
|
||||
|
||||
```python
|
||||
from semantica.kg.similarity_calculator import SimilarityCalculator
|
||||
from semantica.kg.node_embeddings import NodeEmbedder
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
embedder = NodeEmbedder()
|
||||
calc = SimilarityCalculator()
|
||||
|
||||
# Get embeddings for both nodes
|
||||
v1 = embedder.find_similar_nodes(graph, n1, top_k=1) # placeholder — use stored embedding
|
||||
v2 = embedder.find_similar_nodes(graph, n2, top_k=1)
|
||||
|
||||
method = method_arg or "cosine"
|
||||
if method == "cosine":
|
||||
score = calc.cosine_similarity(vector1=v1, vector2=v2)
|
||||
elif method == "euclidean":
|
||||
score = calc.euclidean_distance(v1, v2)
|
||||
elif method == "manhattan":
|
||||
score = calc.manhattan_distance(v1, v2)
|
||||
elif method == "correlation":
|
||||
score = calc.correlation_similarity(v1, v2)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Similarity: "<n1>" ↔ "<n2>"
|
||||
Method: cosine
|
||||
Score: 0.847
|
||||
|
||||
Interpretation: HIGH similarity (>0.8)
|
||||
Shared neighbors: K
|
||||
Common node types: [types]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `predict-link <n1> <n2> [--method cosine|jaccard|adamic-adar|common-neighbors]`
|
||||
|
||||
Score the likelihood of a relationship between two nodes.
|
||||
|
||||
```python
|
||||
from semantica.kg.link_predictor import LinkPredictor
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
predictor = LinkPredictor()
|
||||
|
||||
# score_link(graph_store, node_id1, node_id2, method=)
|
||||
score = predictor.score_link(
|
||||
graph_store=graph,
|
||||
node_id1=n1,
|
||||
node_id2=n2,
|
||||
method=method_arg or None,
|
||||
)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Link Prediction: "<n1>" → "<n2>"
|
||||
Method: cosine
|
||||
Score: 0.723 (threshold: 0.5 → LIKELY)
|
||||
|
||||
Recommendation: This link is LIKELY to be meaningful.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `top-links <node_id> [--top N] [--method <method>]`
|
||||
|
||||
Find the top-N most likely new connections for a node.
|
||||
|
||||
```python
|
||||
from semantica.kg.link_predictor import LinkPredictor
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
predictor = LinkPredictor()
|
||||
|
||||
top = predictor.predict_top_links(
|
||||
graph_store=graph,
|
||||
node_id=node_id,
|
||||
top_k=int(top_n) if top_n else 10,
|
||||
method=method_arg or None,
|
||||
)
|
||||
```
|
||||
|
||||
Return: `| Rank | Target Node | Type | Score | Existing Link? |`
|
||||
|
||||
---
|
||||
|
||||
## `batch <query_node> [--against <n1,n2,...>] [--top N]`
|
||||
|
||||
Score similarity between a query node and a set of target nodes (or all nodes).
|
||||
|
||||
```python
|
||||
from semantica.kg.similarity_calculator import SimilarityCalculator
|
||||
from semantica.kg.node_embeddings import NodeEmbedder
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
embedder = NodeEmbedder()
|
||||
calc = SimilarityCalculator()
|
||||
|
||||
# Get query embedding and all target embeddings
|
||||
query_vec = ... # from stored node2vec_embedding
|
||||
target_embeddings = {n: embedder.get_embedding(n) for n in targets}
|
||||
|
||||
scores = calc.batch_similarity(
|
||||
embeddings=target_embeddings,
|
||||
query_embedding=query_vec,
|
||||
top_k=int(top_n) if top_n else 20,
|
||||
)
|
||||
```
|
||||
|
||||
Return: `| Node | Type | Score |` sorted descending.
|
||||
|
||||
---
|
||||
|
||||
## `pairwise [--labels <t1,t2>] [--method cosine|euclidean]`
|
||||
|
||||
Compute all pairwise similarities among a set of nodes.
|
||||
|
||||
```python
|
||||
from semantica.kg.similarity_calculator import SimilarityCalculator
|
||||
|
||||
calc = SimilarityCalculator()
|
||||
|
||||
pairwise = calc.pairwise_similarity(
|
||||
embeddings=embeddings_dict,
|
||||
method=method_arg or None,
|
||||
)
|
||||
```
|
||||
|
||||
Show as a heatmap summary — top-5 most similar pairs and bottom-5 most dissimilar pairs. Full matrix on request.
|
||||
|
||||
Also use `AgentContext.predict_decision_relationships(decision_id, top_k)` when working within decision graphs for relationship prediction enriched with KG algorithms.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: explain
|
||||
description: Explain Semantica reasoning, decision logic, and graph results with traceability, causal context, and human-readable rationale.
|
||||
---
|
||||
|
||||
# /semantica:explain
|
||||
|
||||
Produce explanations for decisions, rules, and graph analytics. Usage: `/semantica:explain <target> [args]`
|
||||
|
||||
`$ARGUMENTS` = explanation target + optional detail level.
|
||||
|
||||
---
|
||||
|
||||
## `decision <decision_id> [--detail <level>]`
|
||||
|
||||
Explain why a decision was reached.
|
||||
|
||||
```python
|
||||
from semantica.reasoning.explanation_generator import ExplanationGenerator
|
||||
|
||||
# For decision explainability in Semantica contexts:
|
||||
decision_trace = ctx.trace_decision_explainability(decision_id=decision_id)
|
||||
|
||||
# For reasoning/proof explanations:
|
||||
generator = ExplanationGenerator(detail_level=detail)
|
||||
explanation = generator.generate_explanation(reasoning_result)
|
||||
```
|
||||
|
||||
Output: decision factors, rule traces, confidence, and suggested next steps.
|
||||
|
||||
---
|
||||
|
||||
## `graph <node_id> [--path N]`
|
||||
|
||||
Explain graph relationships and why a node is connected.
|
||||
|
||||
```python
|
||||
# Use AgentContext explainability + causal tracing for graph-connected decisions
|
||||
graph_explanation = ctx.trace_decision_explainability(decision_id=node_id)
|
||||
upstream = ctx.get_causal_chain(decision_id=node_id, direction="upstream", max_depth=depth)
|
||||
downstream = ctx.get_causal_chain(decision_id=node_id, direction="downstream", max_depth=depth)
|
||||
```
|
||||
|
||||
Return: cause/effect chains, supporting evidence, and relevant metadata.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: export
|
||||
description: Export Semantica graphs, results, and provenance to JSON, RDF, Parquet, CSV, GraphML, and other formats.
|
||||
---
|
||||
|
||||
# /semantica:export
|
||||
|
||||
Export knowledge graph data. Usage: `/semantica:export <format> [args]`
|
||||
|
||||
`$ARGUMENTS` = format + optional target or destination.
|
||||
|
||||
---
|
||||
|
||||
## `json [--output <path>] [--filter <query>]`
|
||||
|
||||
Export graph data as JSON.
|
||||
|
||||
```python
|
||||
from semantica.export.methods import export_json
|
||||
|
||||
export_json(data=graph_data, file_path=output, format='json')
|
||||
```
|
||||
|
||||
Output: JSON file or inline JSON payload.
|
||||
|
||||
---
|
||||
|
||||
## `rdf [--format turtle|rdfxml|jsonld|ntriples|n3] [--output <path>]`
|
||||
|
||||
Export the graph in RDF serialization.
|
||||
|
||||
```python
|
||||
from semantica.export.methods import export_rdf
|
||||
|
||||
export_rdf(data=graph_data, file_path=output, format='turtle')
|
||||
```
|
||||
|
||||
Return: RDF text or file path.
|
||||
|
||||
---
|
||||
|
||||
## `parquet [--output <path>]`
|
||||
|
||||
Export nodes and edges to Parquet for analytics.
|
||||
|
||||
```python
|
||||
from semantica.export.methods import export_parquet
|
||||
|
||||
export_parquet(data=graph_data, file_path=output, compression='snappy')
|
||||
```
|
||||
|
||||
Output: Parquet dataset ready for downstream processing.
|
||||
|
||||
---
|
||||
|
||||
## `graphml|gexf|dot [--output <path>]`
|
||||
|
||||
Export the graph to a supported graph format.
|
||||
|
||||
```python
|
||||
from semantica.export import GraphExporter
|
||||
|
||||
exporter = GraphExporter(format='graphml', include_attributes=True)
|
||||
exporter.export(graph_data, output)
|
||||
```
|
||||
|
||||
Output: Graph format file suitable for visualization tools.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: extract
|
||||
description: Run the full Semantica semantic extraction pipeline on a file or selected text — NER, relations, events, coreference resolution, triplets, and validation. Clears result cache before each run. Returns Markdown tables with entity/relation/event/triplet results and inline validator warnings.
|
||||
---
|
||||
|
||||
# /semantica:extract
|
||||
|
||||
Run the full extraction pipeline. Usage: `/semantica:extract [file_path | "inline text"]`
|
||||
|
||||
`$ARGUMENTS` = file path, inline text in quotes, or blank (uses active editor file).
|
||||
|
||||
---
|
||||
|
||||
## Steps
|
||||
|
||||
**1. Resolve the source.**
|
||||
- If `$ARGUMENTS` is a readable file path → `text = open(path).read()`
|
||||
- If it's quoted inline text → use directly
|
||||
- If blank → use the active editor file
|
||||
|
||||
**2. Clear the result cache** to prevent cross-invocation pollution:
|
||||
|
||||
```python
|
||||
from semantica.semantic_extract.cache import _result_cache
|
||||
_result_cache.clear()
|
||||
```
|
||||
|
||||
**3. Run the full pipeline:**
|
||||
|
||||
```python
|
||||
from semantica.semantic_extract import (
|
||||
NamedEntityRecognizer,
|
||||
RelationExtractor,
|
||||
EventDetector,
|
||||
CoreferenceResolver,
|
||||
TripletExtractor,
|
||||
ExtractionValidator,
|
||||
)
|
||||
|
||||
# Named Entity Recognition
|
||||
ner = NamedEntityRecognizer()
|
||||
entities = ner.extract(text)
|
||||
|
||||
# Relation Extraction
|
||||
rel = RelationExtractor()
|
||||
relations = rel.extract(text)
|
||||
|
||||
# Event Detection
|
||||
evt = EventDetector()
|
||||
events = evt.extract(text)
|
||||
|
||||
# Coreference Resolution — resolve pronouns/aliases before extraction
|
||||
coref = CoreferenceResolver()
|
||||
resolved_text = coref.resolve(text)
|
||||
|
||||
# Triplet Extraction (subject–predicate–object)
|
||||
triplet = TripletExtractor()
|
||||
triplets = triplet.extract(resolved_text)
|
||||
|
||||
# Validate quality
|
||||
validator = ExtractionValidator()
|
||||
issues = validator.validate(entities, relations)
|
||||
```
|
||||
|
||||
**4. Report validator warnings** above results:
|
||||
```
|
||||
⚠ ExtractionValidator: <warning message>
|
||||
```
|
||||
|
||||
**5. Return results as Markdown tables:**
|
||||
|
||||
**Entities** (N total)
|
||||
| Label | Type | Confidence | Span |
|
||||
|-------|------|------------|------|
|
||||
|
||||
**Relations** (M total)
|
||||
| Source | Relation Type | Target | Confidence |
|
||||
|--------|---------------|--------|------------|
|
||||
|
||||
**Events** (K total)
|
||||
| Label | Type | Participants | Confidence |
|
||||
|-------|------|--------------|------------|
|
||||
|
||||
**Triplets** (J total)
|
||||
| Subject | Predicate | Object | Confidence |
|
||||
|---------|-----------|--------|------------|
|
||||
|
||||
**6. Summary line:**
|
||||
```
|
||||
Extracted: N entities, M relations, K events, J triplets — from <source>
|
||||
```
|
||||
|
||||
For large files (>50KB), process in chunks and show a progress indicator. Highlight any entities appearing in the context graph already (`ContextGraph.has_node(label)`) with `[in graph]` tag.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: ingest
|
||||
description: Ingest data from files, databases, APIs, or streams into Semantica knowledge graphs with schema mapping and entity linking.
|
||||
---
|
||||
|
||||
# /semantica:ingest
|
||||
|
||||
Ingest new data into the knowledge graph. Usage: `/semantica:ingest <source> [args]`
|
||||
|
||||
`$ARGUMENTS` = source type + optional file path, connection string, or dataset identifier.
|
||||
|
||||
---
|
||||
|
||||
## `file <path> [--format json|csv|yaml|xml]`
|
||||
|
||||
Ingest structured data from a local file.
|
||||
|
||||
```python
|
||||
from semantica.ingest import ingest_file
|
||||
|
||||
data = ingest_file(file_path=path, method='file', file_format=file_format)
|
||||
```
|
||||
|
||||
Output: imported node/edge count and ingestion summary.
|
||||
|
||||
---
|
||||
|
||||
## `db <connection> [--query <sql>]`
|
||||
|
||||
Ingest data from a database source.
|
||||
|
||||
```python
|
||||
from semantica.ingest import ingest_database
|
||||
|
||||
result = ingest_database(connection_string=conn, query=query)
|
||||
```
|
||||
|
||||
Return: rows ingested, mapped entities, and warnings.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: ontology
|
||||
description: Manage ontology schemas, concepts, relationships, and alignments for Semantica knowledge graphs.
|
||||
---
|
||||
|
||||
# /semantica:ontology
|
||||
|
||||
Manage ontology definitions and validation. Usage: `/semantica:ontology <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = task + optional ontology item or schema file.
|
||||
|
||||
---
|
||||
|
||||
## `describe <concept>`
|
||||
|
||||
Show ontology concept details.
|
||||
|
||||
```python
|
||||
from semantica.ontology import OntologyManager
|
||||
|
||||
manager = OntologyManager()
|
||||
concept = manager.get_concept(concept_name)
|
||||
```
|
||||
|
||||
Output: properties, relationships, inherited types, and examples.
|
||||
|
||||
---
|
||||
|
||||
## `validate [--schema <file>]`
|
||||
|
||||
Validate the graph or schema against the ontology.
|
||||
|
||||
```python
|
||||
result = manager.validate_graph(graph=graph, schema_file=schema_file)
|
||||
```
|
||||
|
||||
Return: validation status, errors, and correction suggestions.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: policy
|
||||
description: Define and enforce policies, access controls, and compliance rules over Semantica knowledge graphs.
|
||||
---
|
||||
|
||||
# /semantica:policy
|
||||
|
||||
Apply policy rules and checks. Usage: `/semantica:policy <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = task + optional policy name, rule set, or target entity.
|
||||
|
||||
---
|
||||
|
||||
## `check [--rule <name>] [--target <id>]`
|
||||
|
||||
Run policy checks against the graph.
|
||||
|
||||
```python
|
||||
from semantica.policy import PolicyEngine
|
||||
|
||||
engine = PolicyEngine()
|
||||
result = engine.check(rule_name=rule_name, target=target)
|
||||
```
|
||||
|
||||
Output: compliance status, failing rules, and remediation guidance.
|
||||
|
||||
---
|
||||
|
||||
## `list`
|
||||
|
||||
List available policy rules and categories.
|
||||
|
||||
```python
|
||||
rules = engine.list_rules()
|
||||
```
|
||||
|
||||
Return: rule name, description, severity, and category.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: provenance
|
||||
description: Trace data lineage, source attribution, audit trails, and provenance assertions in Semantica graphs.
|
||||
---
|
||||
|
||||
# /semantica:provenance
|
||||
|
||||
Inspect provenance metadata. Usage: `/semantica:provenance <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = task + optional node, edge, or time range.
|
||||
|
||||
---
|
||||
|
||||
## `trace <node_id> [--depth N]`
|
||||
|
||||
Trace the provenance of a node or fact.
|
||||
|
||||
```python
|
||||
from semantica.provenance import ProvenanceTracer
|
||||
|
||||
tracer = ProvenanceTracer()
|
||||
trace = tracer.trace_node(node_id=node_id, depth=depth)
|
||||
```
|
||||
|
||||
Output: source chain, authors, timestamps, and validation status.
|
||||
|
||||
---
|
||||
|
||||
## `audit [--since <ts>] [--actor <id>]`
|
||||
|
||||
View audit logs for graph changes.
|
||||
|
||||
```python
|
||||
audit_log = tracer.get_audit_log(since=since, actor=actor)
|
||||
```
|
||||
|
||||
Return: change events, actor, affected objects, and action details.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: query
|
||||
description: Query the Semantica knowledge graph using SPARQL, Cypher, keyword search, and structured graph query patterns.
|
||||
---
|
||||
|
||||
# /semantica:query
|
||||
|
||||
Run graph queries and search. Usage: `/semantica:query <mode> [args]`
|
||||
|
||||
`$ARGUMENTS` = query mode + query string or filter.
|
||||
|
||||
---
|
||||
|
||||
## `sparql <query>`
|
||||
|
||||
Execute a SPARQL query against the graph.
|
||||
|
||||
```python
|
||||
from semantica.query import QueryEngine
|
||||
|
||||
engine = QueryEngine()
|
||||
results = engine.query_sparql(query)
|
||||
```
|
||||
|
||||
Return: query bindings as a Markdown table.
|
||||
|
||||
---
|
||||
|
||||
## `cypher <query>`
|
||||
|
||||
Execute a Cypher-like query.
|
||||
|
||||
```python
|
||||
results = engine.query_cypher(query)
|
||||
```
|
||||
|
||||
Output: node/relationship results and path summaries.
|
||||
|
||||
---
|
||||
|
||||
## `search <keywords> [--filter <type>]`
|
||||
|
||||
Search graph entities by keyword.
|
||||
|
||||
```python
|
||||
results = engine.search(keywords=keywords, filter_type=filter_type)
|
||||
```
|
||||
|
||||
Return: ranked matches with entity types and relevance scores.
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
name: reason
|
||||
description: Run reasoning over the Semantica knowledge graph — deductive logic, abductive hypothesis generation, Datalog programs, SPARQL queries, Rete network evaluation. Uses DeductiveReasoner, AbductiveReasoner, DatalogReasoner, SPARQLReasoner, ReteEngine. Sub-commands: deductive, abductive, datalog, sparql, rete, prove, hypotheses.
|
||||
---
|
||||
|
||||
# /semantica:reason
|
||||
|
||||
Apply reasoning over the knowledge graph. Usage: `/semantica:reason <mode> [args]`
|
||||
|
||||
`$ARGUMENTS` = reasoning mode + rules/observations/query.
|
||||
|
||||
---
|
||||
|
||||
## `deductive [--facts '<json-list>'] [--rules '<rule1>|<rule2>']`
|
||||
|
||||
Apply deductive rules to known facts to derive new conclusions.
|
||||
|
||||
```python
|
||||
from semantica.reasoning.deductive_reasoner import DeductiveReasoner, Premise
|
||||
|
||||
reasoner = DeductiveReasoner()
|
||||
|
||||
# Add base facts to working memory
|
||||
# Facts can be strings like "Person(John)" or structured dicts
|
||||
import json
|
||||
facts = json.loads(facts_json) if facts_json else []
|
||||
reasoner.add_facts(facts)
|
||||
|
||||
# Apply logic with explicit premises
|
||||
# Premise objects have: statement, confidence, source
|
||||
premises = [
|
||||
Premise(statement=fact, confidence=1.0)
|
||||
for fact in facts
|
||||
]
|
||||
|
||||
conclusions = reasoner.apply_logic(premises=premises)
|
||||
```
|
||||
|
||||
Return: `| Conclusion | Triggering Premises | Confidence | Rule Applied |`
|
||||
|
||||
If zero rules given, run `reasoner.prove_theorem()` on any provided theorem:
|
||||
```python
|
||||
proof = reasoner.prove_theorem(theorem=theorem_text)
|
||||
```
|
||||
|
||||
Output: `Proof: <proof.steps> | Valid: YES / NO`
|
||||
|
||||
---
|
||||
|
||||
## `prove <theorem> [--facts '<json-list>']`
|
||||
|
||||
Prove or disprove a theorem against known facts.
|
||||
|
||||
```python
|
||||
from semantica.reasoning.deductive_reasoner import DeductiveReasoner
|
||||
|
||||
reasoner = DeductiveReasoner()
|
||||
import json
|
||||
reasoner.add_facts(json.loads(facts_json) if facts_json else [])
|
||||
|
||||
proof = reasoner.prove_theorem(theorem=theorem)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Theorem: "<theorem>"
|
||||
Result: PROVED ✓ | DISPROVED ✗ | UNDECIDABLE ⚠
|
||||
|
||||
Proof steps:
|
||||
1. <premise> — <justification>
|
||||
2. ...
|
||||
→ QED: <theorem>
|
||||
|
||||
Confidence: <proof.confidence>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `abductive <observation> [--knowledge '<json-list>'] [--top N]`
|
||||
|
||||
Generate and rank hypotheses that explain an observation.
|
||||
|
||||
```python
|
||||
from semantica.reasoning.abductive_reasoner import (
|
||||
AbductiveReasoner, Observation
|
||||
)
|
||||
|
||||
reasoner = AbductiveReasoner()
|
||||
|
||||
import json
|
||||
if knowledge_json:
|
||||
reasoner.add_knowledge(json.loads(knowledge_json))
|
||||
|
||||
obs = Observation(description=observation)
|
||||
|
||||
# Generate all hypotheses then rank them
|
||||
hypotheses = reasoner.generate_hypotheses(observations=[obs])
|
||||
ranked = reasoner.rank_hypotheses(hypotheses)
|
||||
best = reasoner.get_best_explanation(obs)
|
||||
|
||||
# Also get full explanations with evidence
|
||||
explanations = reasoner.find_explanations(observations=[obs])
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Abductive Reasoning for: "<observation>"
|
||||
|
||||
Best explanation:
|
||||
<best.description> (confidence: 0.87)
|
||||
|
||||
All hypotheses (ranked):
|
||||
| Rank | Hypothesis | Confidence | Supporting Evidence |
|
||||
| 1 | <hyp> | 0.87 | <evidence> |
|
||||
| 2 | ...
|
||||
|
||||
Full explanations:
|
||||
Explanation 1: <explanation.summary>
|
||||
Evidence: <evidence items>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `datalog <program>`
|
||||
|
||||
Evaluate a Datalog program over graph facts.
|
||||
|
||||
```python
|
||||
from semantica.reasoning.datalog_reasoner import DatalogReasoner
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
reasoner = DatalogReasoner()
|
||||
|
||||
# program is a string of Datalog rules and queries
|
||||
results = reasoner.evaluate(program=program, graph=graph)
|
||||
```
|
||||
|
||||
Return derived tuples as a relation table. Show rule derivation counts.
|
||||
|
||||
---
|
||||
|
||||
## `sparql <query>`
|
||||
|
||||
Run a SPARQL query over the knowledge graph and return results.
|
||||
|
||||
```python
|
||||
from semantica.reasoning.sparql_reasoner import SPARQLReasoner
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
reasoner = SPARQLReasoner()
|
||||
|
||||
results = reasoner.query(sparql_query=query, graph=graph)
|
||||
```
|
||||
|
||||
Return as a Markdown table with bound variable columns matching the SELECT clause.
|
||||
|
||||
---
|
||||
|
||||
## `rete [--rules '<rule1>|<rule2>'] [--facts '<json-list>']`
|
||||
|
||||
Incremental rule evaluation using the Rete network with working memory.
|
||||
|
||||
```python
|
||||
from semantica.reasoning.rete_engine import ReteEngine
|
||||
import json
|
||||
|
||||
engine = ReteEngine()
|
||||
|
||||
rules = rules_str.split("|") if rules_str else []
|
||||
facts = json.loads(facts_json) if facts_json else []
|
||||
|
||||
engine.load_rules(rules)
|
||||
engine.process_facts(facts)
|
||||
activations = engine.get_activations()
|
||||
```
|
||||
|
||||
Return: `| Rule Fired | Variable Bindings | Working Memory Delta | Activation Order |`
|
||||
|
||||
---
|
||||
|
||||
## `hypotheses "<scenario>" [--knowledge '<json-list>'] [--top N]`
|
||||
|
||||
Generate the top-N most probable explanations for a complex scenario.
|
||||
|
||||
```python
|
||||
from semantica.reasoning.abductive_reasoner import AbductiveReasoner, Observation
|
||||
import json
|
||||
|
||||
reasoner = AbductiveReasoner()
|
||||
if knowledge_json:
|
||||
reasoner.add_knowledge(json.loads(knowledge_json))
|
||||
|
||||
obs = Observation(description=scenario)
|
||||
hypotheses = reasoner.generate_hypotheses(observations=[obs])
|
||||
ranked = reasoner.rank_hypotheses(hypotheses)
|
||||
top_n = ranked[:int(n) if n else 5]
|
||||
```
|
||||
|
||||
For each hypothesis also show: what evidence supports it, what would falsify it, and which is the most parsimonious (fewest assumptions).
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: temporal
|
||||
description: Temporal graph operations on Semantica — scoped queries at a point in time, graph snapshots, node change timelines, temporal causal analysis, and graph state reconstruction. Uses AgentContext.find_precedents(as_of=), ContextGraph.state_at(), CausalChainAnalyzer.trace_at_time(), and TemporalQueryRewriter. Sub-commands: query, snapshot, timeline, causal-at, precedents-at.
|
||||
---
|
||||
|
||||
# /semantica:temporal
|
||||
|
||||
Temporal graph operations. Usage: `/semantica:temporal <sub-command> [args]`
|
||||
|
||||
`$ARGUMENTS` = sub-command + query/node + date expression.
|
||||
|
||||
---
|
||||
|
||||
## `query "<question>" [at|before|after <date>]`
|
||||
|
||||
Temporally-scoped natural-language graph query.
|
||||
|
||||
```python
|
||||
from semantica.kg.temporal_query_rewriter import TemporalQueryRewriter
|
||||
from semantica.kg.temporal_normalizer import TemporalNormalizer
|
||||
|
||||
normalizer = TemporalNormalizer()
|
||||
# Normalize natural date expressions: "last month", "Q3 2024", "2025-01-15"
|
||||
date = normalizer.normalize(date_expr)
|
||||
|
||||
rewriter = TemporalQueryRewriter()
|
||||
# Rewrite query with temporal constraint
|
||||
rewritten = rewriter.rewrite(
|
||||
query=question,
|
||||
temporal_constraint={"op": direction, "value": date}, # op: "at"|"before"|"after"
|
||||
)
|
||||
```
|
||||
|
||||
Then run the rewritten query through `AgentContext.retrieve()` or `ContextGraph.query()`.
|
||||
|
||||
Return ranked results with `Valid From`, `Valid Until`, `Active At <date>` columns. Mark nodes that were not yet created at the target time as `[not yet created]`.
|
||||
|
||||
---
|
||||
|
||||
## `snapshot <date>`
|
||||
|
||||
Reconstruct the full graph state as it existed at a specific point in time.
|
||||
|
||||
```python
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph(advanced_analytics=True)
|
||||
|
||||
# state_at returns a dict snapshot of the graph at that timestamp
|
||||
snapshot = graph.state_at(timestamp=date) # ISO string or datetime
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Graph snapshot at <date>:
|
||||
Nodes: N (M added since prev snapshot, K removed)
|
||||
Edges: P
|
||||
Density: 0.21
|
||||
Communities: Q
|
||||
|
||||
Active decision categories at <date>:
|
||||
| Category | Count | Avg Confidence |
|
||||
|
||||
Top 10 nodes (by degree at <date>):
|
||||
| Node | Type | Degree |
|
||||
|
||||
[Compact Mermaid graph TD — top-10 most connected nodes at that time]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `timeline <node_id>`
|
||||
|
||||
Show attribute and relationship changes for a node across its full history.
|
||||
|
||||
```python
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
|
||||
# Use state_at() at multiple time points to reconstruct history
|
||||
# Check add_node timestamps and edge addition times from graph data
|
||||
node_data = graph.find_node(node_id)
|
||||
```
|
||||
|
||||
Output as Markdown timeline:
|
||||
```
|
||||
Timeline for "<node_id>" (<type>):
|
||||
|
||||
<timestamp> CREATED
|
||||
Properties: {confidence: 0.71, category: "loan_approval"}
|
||||
Source: extraction/pipeline
|
||||
|
||||
<timestamp> UPDATED
|
||||
confidence: 0.71 → 0.91 [source: review]
|
||||
|
||||
<timestamp> RELATIONSHIP ADDED
|
||||
"<node_id>" →[CAUSED]→ "Decision_B"
|
||||
|
||||
<timestamp> RELATIONSHIP REMOVED
|
||||
"<node_id>" →[PRECEDED_BY]→ "Decision_X" (superseded)
|
||||
|
||||
Total lifespan: <duration>
|
||||
Current state: <active|superseded>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `causal-at <decision_id> <date> [--direction upstream|downstream]`
|
||||
|
||||
Trace a causal chain as it existed at a specific point in time.
|
||||
|
||||
```python
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
from semantica.context import AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True)
|
||||
analyzer = CausalChainAnalyzer(graph_store=ctx.graph_store)
|
||||
|
||||
historical_chain = analyzer.trace_at_time(
|
||||
event_id=decision_id,
|
||||
at_time=date, # ISO string or datetime
|
||||
direction=direction or "upstream",
|
||||
max_depth=10,
|
||||
)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Historical causal chain for <decision_id> at <date>:
|
||||
Direction: upstream (what caused it?)
|
||||
|
||||
[Mermaid graph TD showing chain as it existed at <date>]
|
||||
|
||||
Decisions present then but not now: [list]
|
||||
Decisions added since then: [list]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `precedents-at "<scenario>" <date> [--category <cat>]`
|
||||
|
||||
Find precedent decisions that existed as of a specific date — useful for auditing what context was available when a decision was made.
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True, advanced_analytics=True)
|
||||
|
||||
# find_precedents supports as_of parameter for temporal precedent search
|
||||
precedents = ctx.find_precedents(
|
||||
scenario=scenario,
|
||||
category=category or None,
|
||||
limit=10,
|
||||
use_hybrid_search=True,
|
||||
include_context=True,
|
||||
include_superseded=False,
|
||||
as_of=date, # Only return precedents that existed at this date
|
||||
)
|
||||
```
|
||||
|
||||
Return: `| Rank | Decision ID | Scenario | Outcome | Confidence | Set Date | Valid Until |`
|
||||
|
||||
Note decisions that were superseded before or after the target date.
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
name: validate
|
||||
description: Validate Semantica pipelines, extraction quality, graph schemas, and ontology consistency. Returns structured error/warning checklists. Uses PipelineValidator, PipelineBuilder.validate_pipeline(), GraphValidator, and OntologyValidator. Sub-commands: pipeline, step, dependencies, extraction, graph, ontology, performance.
|
||||
---
|
||||
|
||||
# /semantica:validate
|
||||
|
||||
Validate pipeline and graph quality. Usage: `/semantica:validate <target> [options]`
|
||||
|
||||
`$ARGUMENTS` = target type + optional config or path.
|
||||
|
||||
---
|
||||
|
||||
## `pipeline [--config '<json>']`
|
||||
|
||||
Validate a full pipeline builder configuration.
|
||||
|
||||
```python
|
||||
from semantica.pipeline.pipeline_builder import PipelineBuilder
|
||||
from semantica.pipeline.pipeline_validator import PipelineValidator
|
||||
|
||||
builder = PipelineBuilder()
|
||||
if config_json:
|
||||
import json
|
||||
builder.build_pipeline(json.loads(config_json))
|
||||
|
||||
# PipelineBuilder has its own quick validate
|
||||
quick = builder.validate_pipeline() # returns Dict
|
||||
|
||||
# PipelineValidator gives full ValidationResult(valid, errors, warnings)
|
||||
# Does NOT raise — always returns a result object
|
||||
validator = PipelineValidator()
|
||||
result = validator.validate(builder)
|
||||
|
||||
# Also check inter-step dependencies
|
||||
deps = validator.check_dependencies(builder)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Pipeline Validation: VALID ✓ | INVALID ✗
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Steps: N registered
|
||||
Valid: M steps
|
||||
|
||||
Errors (K):
|
||||
✗ [step_name] <error message>
|
||||
|
||||
Warnings (J):
|
||||
⚠ [step_name] <warning message>
|
||||
|
||||
Dependencies:
|
||||
✓ All dependencies resolved
|
||||
✗ Step "<name>" depends on missing step "<dep>"
|
||||
|
||||
Result: <valid> — K errors, J warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `step <step_name> [--type <type>] [--constraints '<json>']`
|
||||
|
||||
Validate a single pipeline step.
|
||||
|
||||
```python
|
||||
from semantica.pipeline.pipeline_builder import PipelineBuilder
|
||||
from semantica.pipeline.pipeline_validator import PipelineValidator
|
||||
import json
|
||||
|
||||
builder = PipelineBuilder()
|
||||
step = builder.get_step(step_name)
|
||||
|
||||
validator = PipelineValidator()
|
||||
result = validator.validate_step(
|
||||
step=step,
|
||||
**json.loads(constraints_json) if constraints_json else {},
|
||||
)
|
||||
```
|
||||
|
||||
Output: same checklist format but scoped to a single step.
|
||||
|
||||
---
|
||||
|
||||
## `dependencies`
|
||||
|
||||
Check all inter-step dependency resolution for the active pipeline.
|
||||
|
||||
```python
|
||||
from semantica.pipeline.pipeline_builder import PipelineBuilder
|
||||
from semantica.pipeline.pipeline_validator import PipelineValidator
|
||||
|
||||
builder = PipelineBuilder()
|
||||
validator = PipelineValidator()
|
||||
|
||||
deps = validator.check_dependencies(builder)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Dependency Graph:
|
||||
| Step | Depends On | Status |
|
||||
| step_A | — | ✓ |
|
||||
| step_B | step_A | ✓ |
|
||||
| step_C | step_X | ✗ MISSING |
|
||||
|
||||
Cycles detected: YES / NO
|
||||
Missing steps: [list]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `extraction <file_path>`
|
||||
|
||||
Validate extraction quality for a file — entity confidence, relation density, coverage.
|
||||
|
||||
```python
|
||||
from semantica.semantic_extract.extraction_validator import ExtractionValidator
|
||||
from semantica.semantic_extract import (
|
||||
NamedEntityRecognizer,
|
||||
RelationExtractor,
|
||||
)
|
||||
from semantica.semantic_extract.cache import _result_cache
|
||||
|
||||
_result_cache.clear() # prevent cross-invocation cache pollution
|
||||
|
||||
text = open(file_path).read()
|
||||
|
||||
ner = NamedEntityRecognizer()
|
||||
rel = RelationExtractor()
|
||||
entities = ner.extract(text)
|
||||
relations = rel.extract(text)
|
||||
|
||||
validator = ExtractionValidator()
|
||||
issues = validator.validate(entities, relations)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Extraction Validation: <file_path>
|
||||
Entities: N extracted
|
||||
Relations: M extracted
|
||||
Avg confidence: 0.83
|
||||
|
||||
Errors (K):
|
||||
✗ <issue>
|
||||
|
||||
Warnings (J):
|
||||
⚠ <warning>
|
||||
|
||||
Quality score: X/100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `graph`
|
||||
|
||||
Check schema conformance, referential integrity, and structural health.
|
||||
|
||||
```python
|
||||
from semantica.kg.graph_validator import GraphValidator
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
validator = GraphValidator(graph)
|
||||
result = validator.validate()
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Graph Validation:
|
||||
Nodes: N | Edges: M
|
||||
Node types: K valid, J unknown
|
||||
|
||||
Referential integrity:
|
||||
✗ Dangling edge: <source> → <missing target>
|
||||
|
||||
Schema conformance:
|
||||
✗ Node "<id>" missing required property "<prop>"
|
||||
|
||||
Result: N errors, M warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `ontology`
|
||||
|
||||
Validate ontology consistency and evaluate competency questions.
|
||||
|
||||
```python
|
||||
from semantica.ontology import OntologyValidator
|
||||
|
||||
validator = OntologyValidator()
|
||||
result = validator.validate()
|
||||
cq_results = validator.evaluate_competency_questions()
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Ontology Validation:
|
||||
Classes: N
|
||||
Properties: M
|
||||
Consistent: YES ✓ | NO ✗
|
||||
|
||||
Competency questions:
|
||||
✓ "Can we find all instances of X?" — answered
|
||||
✗ "Is Y a subclass of Z?" — failed: <reason>
|
||||
|
||||
Result: N consistency errors, M CQ failures
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `performance`
|
||||
|
||||
Validate pipeline performance characteristics — bottlenecks, parallelism, and resource use.
|
||||
|
||||
```python
|
||||
from semantica.pipeline.pipeline_builder import PipelineBuilder
|
||||
from semantica.pipeline.pipeline_validator import PipelineValidator
|
||||
|
||||
builder = PipelineBuilder()
|
||||
pipeline = builder.build()
|
||||
validator = PipelineValidator()
|
||||
|
||||
perf = validator.validate_performance(pipeline)
|
||||
```
|
||||
|
||||
Output: step-by-step timing estimates, parallelism opportunities, and recommended parallelism level.
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: visualize
|
||||
description: Visualize the Semantica knowledge graph — topology, centrality, communities, paths, embeddings, decision insights, and temporal evolution. Uses GraphAnalyzer, CentralityCalculator, CommunityDetector, PathFinder, and ContextGraph analytics. Sub-commands: topology, centrality, community, path, decision-graph, insights, temporal, embedding.
|
||||
---
|
||||
|
||||
# /semantica:visualize
|
||||
|
||||
Render graph visualizations as Mermaid, ASCII, or structured Markdown. Usage: `/semantica:visualize <sub-command> [args]`
|
||||
|
||||
`$ARGUMENTS` = sub-command + optional node label or filter.
|
||||
|
||||
---
|
||||
|
||||
## `topology [--filter <node_type>]`
|
||||
|
||||
Full graph structure analysis — node types, edge distribution, connectivity metrics.
|
||||
|
||||
```python
|
||||
from semantica.kg.graph_analyzer import GraphAnalyzer
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph(advanced_analytics=True)
|
||||
analyzer = GraphAnalyzer()
|
||||
|
||||
# Comprehensive analysis
|
||||
analysis = analyzer.analyze_graph(graph=graph.to_dict())
|
||||
metrics = analyzer.compute_metrics(graph=graph)
|
||||
connectivity = analyzer.analyze_connectivity(graph=graph)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Graph Topology:
|
||||
Nodes: N (M types)
|
||||
Edges: P
|
||||
Density: 0.23
|
||||
Avg degree: 4.7
|
||||
Connected: YES / NO (K components)
|
||||
|
||||
Node type distribution:
|
||||
[Mermaid pie chart]
|
||||
| Type | Count | % | Avg Degree |
|
||||
|
||||
Top-10 connected nodes:
|
||||
| Node | Type | Degree | Betweenness |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `centrality [--type degree|betweenness|closeness|eigenvector|pagerank|all] [--top N]`
|
||||
|
||||
Calculate and rank nodes by centrality.
|
||||
|
||||
```python
|
||||
from semantica.kg.centrality_calculator import CentralityCalculator
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
calc = CentralityCalculator()
|
||||
|
||||
if centrality_type == "all" or not centrality_type:
|
||||
scores = calc.calculate_all_centrality(graph=graph)
|
||||
elif centrality_type == "degree":
|
||||
scores = calc.calculate_degree_centrality(graph=graph)
|
||||
elif centrality_type == "betweenness":
|
||||
scores = calc.calculate_betweenness_centrality(graph=graph)
|
||||
elif centrality_type == "closeness":
|
||||
scores = calc.calculate_closeness_centrality(graph=graph)
|
||||
elif centrality_type == "eigenvector":
|
||||
scores = calc.calculate_eigenvector_centrality(graph=graph)
|
||||
elif centrality_type == "pagerank":
|
||||
scores = calc.calculate_pagerank(
|
||||
graph=graph,
|
||||
max_iterations=20,
|
||||
damping_factor=0.85,
|
||||
)
|
||||
```
|
||||
|
||||
Return: `| Rank | Node | Type | Degree | Betweenness | Closeness | Eigenvector | PageRank |`
|
||||
|
||||
For a single node, also call `ContextGraph.get_node_centrality(node_id)` and `get_node_importance(node_id)`.
|
||||
|
||||
---
|
||||
|
||||
## `community [--algorithm louvain|leiden|label-propagation|overlapping]`
|
||||
|
||||
Detect and visualize graph communities/clusters.
|
||||
|
||||
```python
|
||||
from semantica.kg.community_detector import CommunityDetector
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
detector = CommunityDetector()
|
||||
|
||||
algorithm = algo_arg or "louvain"
|
||||
|
||||
if algorithm == "louvain":
|
||||
result = detector.detect_communities_louvain(graph, resolution=1.0)
|
||||
elif algorithm == "leiden":
|
||||
result = detector.detect_communities_leiden(graph, resolution=1.0)
|
||||
elif algorithm == "label-propagation":
|
||||
result = detector.detect_communities_label_propagation(graph)
|
||||
elif algorithm == "overlapping":
|
||||
result = detector.detect_overlapping_communities(graph)
|
||||
else:
|
||||
result = detector.detect_communities(graph, algorithm=algorithm)
|
||||
|
||||
structure = detector.analyze_community_structure(graph, result)
|
||||
metrics = detector.calculate_community_metrics(graph, result)
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Community Detection (algorithm: louvain)
|
||||
Communities: N
|
||||
Modularity: 0.71
|
||||
|
||||
Community summary:
|
||||
| ID | Size | Top Node | Internal Density | Bridge Nodes |
|
||||
|
||||
[Mermaid graph TD — nodes colored/grouped by community ID]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `path <n1> <n2> [--k N] [--algorithm bfs|dijkstra|astar|k-shortest]`
|
||||
|
||||
Find and visualize paths between two nodes.
|
||||
|
||||
```python
|
||||
from semantica.kg.path_finder import PathFinder
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
finder = PathFinder()
|
||||
|
||||
k = int(k_arg) if k_arg else 3
|
||||
|
||||
if algorithm == "bfs":
|
||||
path = finder.bfs_shortest_path(graph, source=n1, target=n2)
|
||||
paths = [path]
|
||||
elif algorithm == "dijkstra":
|
||||
path = finder.dijkstra_shortest_path(graph, source=n1, target=n2)
|
||||
paths = [path]
|
||||
else: # default: k-shortest
|
||||
paths = finder.find_k_shortest_paths(graph, source=n1, target=n2, k=k)
|
||||
|
||||
lengths = [finder.path_length(graph, p) for p in paths]
|
||||
```
|
||||
|
||||
Output as Mermaid `sequenceDiagram` for each path:
|
||||
```
|
||||
Path 1 (length: 2.3):
|
||||
n1 →[rel_type]→ Middle →[rel_type]→ n2
|
||||
|
||||
Path 2 (length: 3.7): ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `decision-graph [--category <cat>] [--depth N]`
|
||||
|
||||
Visualize the decision influence graph for a category or all decisions.
|
||||
|
||||
```python
|
||||
from semantica.context import ContextGraph
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
from semantica.context import AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True, advanced_analytics=True)
|
||||
graph = ContextGraph(advanced_analytics=True)
|
||||
|
||||
# Get decision insights
|
||||
insights = graph.get_decision_insights()
|
||||
|
||||
# Build causal network
|
||||
analyzer = CausalChainAnalyzer(graph_store=ctx.graph_store)
|
||||
network = analyzer.analyze_causal_network()
|
||||
```
|
||||
|
||||
Output as Mermaid `graph TD` with:
|
||||
- Node size proportional to causal impact score
|
||||
- Color by outcome (green=approved, red=rejected, yellow=deferred)
|
||||
- Edge labels showing relationship type
|
||||
|
||||
---
|
||||
|
||||
## `insights`
|
||||
|
||||
Comprehensive decision analytics dashboard.
|
||||
|
||||
```python
|
||||
from semantica.context import ContextGraph, AgentContext
|
||||
|
||||
ctx = AgentContext(decision_tracking=True, advanced_analytics=True, kg_algorithms=True)
|
||||
graph = ContextGraph(advanced_analytics=True, centrality_analysis=True)
|
||||
|
||||
insights = graph.get_decision_insights()
|
||||
summary = graph.get_decision_summary()
|
||||
graph_summary = graph.get_graph_summary()
|
||||
context_insights = ctx.get_context_insights()
|
||||
```
|
||||
|
||||
Output a full analytics dashboard:
|
||||
```
|
||||
Decision Intelligence Dashboard
|
||||
════════════════════════════════
|
||||
Decisions: N total (M active)
|
||||
Categories: K unique
|
||||
Avg confidence: 0.87
|
||||
Outcome split: approved 55% | rejected 30% | deferred 15%
|
||||
Causal chains: P chains, longest: Q hops
|
||||
Loops detected: R circular dependencies
|
||||
|
||||
Graph health:
|
||||
Nodes: N | Edges: M | Density: 0.23
|
||||
Communities: K | Isolated nodes: J
|
||||
|
||||
[Mermaid pie — outcome distribution]
|
||||
[Mermaid bar — decisions by category]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `temporal [--node <id>] [--start <date>] [--end <date>]`
|
||||
|
||||
Analyze how the graph evolved over time.
|
||||
|
||||
```python
|
||||
from semantica.kg.graph_analyzer import GraphAnalyzer
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
analyzer = GraphAnalyzer()
|
||||
|
||||
evolution = analyzer.analyze_temporal_evolution(
|
||||
graph=graph,
|
||||
start_time=start_date or None,
|
||||
end_time=end_date or None,
|
||||
metrics=["node_count", "edge_count", "density", "communities"],
|
||||
)
|
||||
|
||||
# For a specific node, use ContextGraph.state_at()
|
||||
if node_id:
|
||||
snapshot = graph.state_at(timestamp=end_date or "now")
|
||||
```
|
||||
|
||||
Output as Markdown timeline with metrics per interval.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,75 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
||||
|
||||
Note: This will impact Vite dev & build performances.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>semantica-explorer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3945
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "semantica-explorer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@sigma/node-border": "^3.0.0",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-communities-louvain": "^2.0.2",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-metrics": "^2.4.0",
|
||||
"graphology-shortest-path": "^2.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"sigma": "^3.0.2",
|
||||
"vis-data": "^8.0.3",
|
||||
"vis-timeline": "^8.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@rolldown/plugin-babel": "^0.2.1",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,432 @@
|
||||
import { lazy, Suspense, useState, type ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Database, FileSearch, GitBranchPlus, Scale, Settings2 } from 'lucide-react';
|
||||
|
||||
const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
|
||||
const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
|
||||
const GraphWorkspace = lazy(() => import('./workspaces/GraphWorkspace/GraphWorkspace').then((module) => ({ default: module.GraphWorkspace })));
|
||||
const ImportExportWorkspace = lazy(() => import('./workspaces/ImportExportWorkspace/ImportExportWorkspace').then((module) => ({ default: module.ImportExportWorkspace })));
|
||||
const LineageDiagram = lazy(() => import('./workspaces/LineageWorkspace/LineageDiagram').then((module) => ({ default: module.LineageDiagram })));
|
||||
const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace })));
|
||||
const SparqlWorkspace = lazy(() => import('./workspaces/SparqlWorkspace/SparqlWorkspace').then((module) => ({ default: module.SparqlWorkspace })));
|
||||
const VocabularyWorkspace = lazy(() => import('./workspaces/VocabularyWorkspace/VocabularyWorkspace').then((module) => ({ default: module.VocabularyWorkspace })));
|
||||
|
||||
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type ExploreView = 'graph' | 'vocabulary';
|
||||
type AnalyzeView = 'sparql' | 'reasoning';
|
||||
type EnrichView = 'import' | 'merge';
|
||||
|
||||
type NavItem = {
|
||||
id: WorkspaceId;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: typeof Database;
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'explore', label: 'Explore', hint: 'Graph and vocabulary browsing', icon: Database },
|
||||
{ id: 'analyze', label: 'Analyze', hint: 'Query and inspect the dataset', icon: FileSearch },
|
||||
{ id: 'decisions', label: 'Decisions', hint: 'Decision chains and precedent review', icon: Scale },
|
||||
{ id: 'enrich', label: 'Enrich', hint: 'Import, export, and merge workflows', icon: GitBranchPlus },
|
||||
{ id: 'manage', label: 'Manage', hint: 'Lineage and governance tooling', icon: Settings2 },
|
||||
];
|
||||
|
||||
const shellStyles = `
|
||||
:root {
|
||||
--app-bg: #07111f;
|
||||
--panel-bg: rgba(7, 17, 31, 0.82);
|
||||
--panel-border: rgba(140, 192, 255, 0.14);
|
||||
--text-main: #ebf3ff;
|
||||
--text-muted: #8fa8c6;
|
||||
--accent: #4aa3ff;
|
||||
--accent-strong: #7fd0ff;
|
||||
--warm: #f2b66d;
|
||||
--success: #4cc38a;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
color: var(--text-main);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(74, 163, 255, 0.12), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(242, 182, 109, 0.08), transparent 26%),
|
||||
linear-gradient(180deg, #091322 0%, #050b15 100%);
|
||||
font-family: "Segoe UI", "SF Pro Display", sans-serif;
|
||||
}
|
||||
|
||||
.app-rail {
|
||||
width: 88px;
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border-right: 1px solid var(--panel-border);
|
||||
background: rgba(3, 9, 18, 0.92);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand-pill {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-main);
|
||||
background: linear-gradient(135deg, rgba(74, 163, 255, 0.22), rgba(127, 208, 255, 0.08));
|
||||
border: 1px solid rgba(127, 208, 255, 0.18);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 18px;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
color: var(--text-main);
|
||||
background: rgba(74, 163, 255, 0.08);
|
||||
border-color: rgba(74, 163, 255, 0.12);
|
||||
}
|
||||
|
||||
.nav-button[data-active='true'] {
|
||||
color: var(--text-main);
|
||||
background: linear-gradient(180deg, rgba(74, 163, 255, 0.18), rgba(74, 163, 255, 0.08));
|
||||
border-color: rgba(127, 208, 255, 0.22);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
background: linear-gradient(180deg, rgba(7, 17, 31, 0.94), rgba(7, 17, 31, 0.82));
|
||||
backdrop-filter: blur(18px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.workspace-header--compact {
|
||||
min-height: 60px;
|
||||
padding: 10px 18px;
|
||||
background: linear-gradient(180deg, rgba(7, 17, 31, 0.92), rgba(7, 17, 31, 0.72));
|
||||
}
|
||||
|
||||
.workspace-header--compact .workspace-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.workspace-header--compact .workspace-subtitle {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.workspace-header-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.workspace-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(74, 163, 255, 0.08);
|
||||
border: 1px solid rgba(127, 208, 255, 0.14);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-kicker::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--accent-strong), var(--warm));
|
||||
box-shadow: 0 0 14px rgba(127, 208, 255, 0.45);
|
||||
}
|
||||
|
||||
.workspace-title-block {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.workspace-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workspace-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workspace-tab {
|
||||
border: 1px solid rgba(127, 208, 255, 0.18);
|
||||
background: rgba(9, 19, 34, 0.56);
|
||||
color: var(--text-muted);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: 160ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-tab[data-active='true'] {
|
||||
color: var(--text-main);
|
||||
background: rgba(74, 163, 255, 0.16);
|
||||
border-color: rgba(127, 208, 255, 0.3);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.workspace-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-loading {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
background: linear-gradient(180deg, rgba(7, 17, 31, 0.8), rgba(5, 11, 21, 0.92));
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workspace-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-height: auto;
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.workspace-header-main {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.workspace-subtitle {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.workspace-tabs {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function WorkspaceShell({
|
||||
title,
|
||||
subtitle,
|
||||
tabs,
|
||||
compact = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
tabs?: ReactNode;
|
||||
compact?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="workspace-shell">
|
||||
<header className={`workspace-header${compact ? " workspace-header--compact" : ""}`}>
|
||||
<div className="workspace-header-main">
|
||||
<div className="workspace-kicker">Workspace</div>
|
||||
<div className="workspace-title-block">
|
||||
<h1 className="workspace-title">{title}</h1>
|
||||
{subtitle ? <div className="workspace-subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{tabs ? <div className="workspace-tabs">{tabs}</div> : null}
|
||||
</header>
|
||||
<div className="workspace-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceFallback() {
|
||||
return <div className="workspace-loading">Loading workspace…</div>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('explore');
|
||||
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
||||
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||
|
||||
const renderWorkspace = () => {
|
||||
if (activeWorkspace === 'explore') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Explore"
|
||||
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
|
||||
compact
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={exploreView === 'graph'} onClick={() => setExploreView('graph')}>
|
||||
Network Explorer
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={exploreView === 'vocabulary'} onClick={() => setExploreView('vocabulary')}>
|
||||
Vocabulary Browser
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
{exploreView === 'graph' ? <GraphWorkspace /> : <VocabularyWorkspace />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'analyze') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Analyze"
|
||||
subtitle="Query the active graph and test inference rules."
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={analyzeView === 'reasoning'} onClick={() => setAnalyzeView('reasoning')}>
|
||||
Reasoning Playground
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={analyzeView === 'sparql'} onClick={() => setAnalyzeView('sparql')}>
|
||||
SPARQL Querying
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
{analyzeView === 'reasoning' ? <ReasoningWorkspace /> : <SparqlWorkspace />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'decisions') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Decisions"
|
||||
subtitle="Inspect decision chains, causal context, and precedent matches."
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
<DecisionWorkspace />
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'enrich') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Enrich"
|
||||
subtitle="Import, export, and reconcile graph entities."
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={enrichView === 'import'} onClick={() => setEnrichView('import')}>
|
||||
Import and Export
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={enrichView === 'merge'} onClick={() => setEnrichView('merge')}>
|
||||
Diff and Merge
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
{enrichView === 'import' ? <ImportExportWorkspace /> : <DiffMergeWorkspace />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Manage"
|
||||
subtitle="Review provenance, lineage, and governance context."
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
<LineageDiagram />
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<style>{shellStyles}</style>
|
||||
<div className="app-shell">
|
||||
<aside className="app-rail">
|
||||
<div className="brand-pill">SEM</div>
|
||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
className="nav-button"
|
||||
data-active={activeWorkspace === id}
|
||||
onClick={() => setActiveWorkspace(id)}
|
||||
title={hint}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="nav-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
{renderWorkspace()}
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,65 @@
|
||||
/* ── Semantica Explorer — Global CSS Reset ── */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
/* Focus ring for accessibility */
|
||||
:focus-visible {
|
||||
outline: 2px solid rgba(88, 166, 255, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(88, 166, 255, 0.25);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(88, 166, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Monospace font for code elements */
|
||||
code, pre, .mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
/* Selection highlight */
|
||||
::selection {
|
||||
background: rgba(88, 166, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Spin animation for loaders */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<App />,
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export function pairRegistryKey(source: string, target: string): string;
|
||||
export function curveGroupForPair(source: string, target: string): string;
|
||||
@@ -0,0 +1,7 @@
|
||||
export function pairRegistryKey(source, target) {
|
||||
return JSON.stringify([source, target]);
|
||||
}
|
||||
|
||||
export function curveGroupForPair(source, target) {
|
||||
return JSON.stringify([source, target]);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import Graph from "graphology";
|
||||
import type {
|
||||
GraphArrowVisibilityPolicy,
|
||||
GraphBadgeKind,
|
||||
GraphEdgeVariant,
|
||||
GraphLabelVisibilityPolicy,
|
||||
GraphNodeShapeVariant,
|
||||
} from "../workspaces/GraphWorkspace/graphTheme";
|
||||
import { curveGroupForPair, pairRegistryKey } from "./edgePairKeys.js";
|
||||
|
||||
|
||||
export const graph = new Graph({
|
||||
type: "directed",
|
||||
multi: true,
|
||||
allowSelfLoops: false
|
||||
});
|
||||
|
||||
|
||||
|
||||
export interface NodeAttributes {
|
||||
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
color: string;
|
||||
baseColor?: string;
|
||||
mutedColor?: string;
|
||||
glowColor?: string;
|
||||
baseSize?: number;
|
||||
visualPriority?: number;
|
||||
labelPriority?: number;
|
||||
semanticGroup?: string;
|
||||
strokeColor?: string;
|
||||
borderColor?: string;
|
||||
borderSize?: number;
|
||||
nodeVariant?: GraphNodeShapeVariant;
|
||||
nodeShapeVariant?: GraphNodeShapeVariant;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeCount?: number;
|
||||
ringColor?: string;
|
||||
haloColor?: string;
|
||||
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
|
||||
highlighted?: boolean;
|
||||
|
||||
nodeType: string;
|
||||
content: string;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
properties: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface EdgeAttributes {
|
||||
edgeId?: string;
|
||||
familyId?: string;
|
||||
sourceId?: string;
|
||||
targetId?: string;
|
||||
|
||||
size?: number;
|
||||
baseSize?: number;
|
||||
color?: string;
|
||||
baseColor?: string;
|
||||
mutedColor?: string;
|
||||
type?: string;
|
||||
curvature?: number;
|
||||
visualPriority?: number;
|
||||
edgeFamily?: "line" | "parallel" | "bidirectional" | "path";
|
||||
isBidirectional?: boolean;
|
||||
curveGroup?: string | null;
|
||||
edgeVariant?: GraphEdgeVariant;
|
||||
arrowVisibilityPolicy?: GraphArrowVisibilityPolicy;
|
||||
relationshipStrength?: number;
|
||||
isParallelPair?: boolean;
|
||||
parallelIndex?: number;
|
||||
parallelCount?: number;
|
||||
familySize?: number;
|
||||
|
||||
|
||||
edgeType: string;
|
||||
weight: number;
|
||||
properties: Record<string, any>;
|
||||
}
|
||||
|
||||
function normalizeParallelMetadataForPair(source: string, target: string): void {
|
||||
const edgeIds: string[] = [];
|
||||
graph.forEachDirectedEdge(source, target, (edgeId) => {
|
||||
edgeIds.push(String(edgeId));
|
||||
});
|
||||
|
||||
const pairCount = edgeIds.length;
|
||||
const familyCounts = new Map<string, number>();
|
||||
edgeIds.forEach((edgeId) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as EdgeAttributes;
|
||||
const familyId = String(attrs.familyId || edgeId);
|
||||
familyCounts.set(familyId, (familyCounts.get(familyId) ?? 0) + 1);
|
||||
});
|
||||
|
||||
edgeIds
|
||||
.sort((left, right) => {
|
||||
const leftAttrs = graph.getEdgeAttributes(left) as EdgeAttributes;
|
||||
const rightAttrs = graph.getEdgeAttributes(right) as EdgeAttributes;
|
||||
const priorityDelta = Number(rightAttrs.visualPriority ?? 0) - Number(leftAttrs.visualPriority ?? 0);
|
||||
if (priorityDelta !== 0) {
|
||||
return priorityDelta;
|
||||
}
|
||||
const weightDelta = Number(rightAttrs.weight ?? 0) - Number(leftAttrs.weight ?? 0);
|
||||
if (weightDelta !== 0) {
|
||||
return weightDelta;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
})
|
||||
.forEach((edgeId, index) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as EdgeAttributes;
|
||||
const familyId = String(attrs.familyId || edgeId);
|
||||
graph.mergeEdgeAttributes(edgeId, {
|
||||
edgeId,
|
||||
familyId,
|
||||
sourceId: source,
|
||||
targetId: target,
|
||||
isParallelPair: pairCount > 1,
|
||||
parallelIndex: index,
|
||||
parallelCount: pairCount,
|
||||
familySize: familyCounts.get(familyId) ?? 1,
|
||||
curveGroup: curveGroupForPair(source, target),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function batchMergeNodes(
|
||||
nodes: { id: string; attributes: NodeAttributes }[]
|
||||
): void {
|
||||
for (const { id, attributes } of nodes) {
|
||||
graph.mergeNode(id, attributes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function batchMergeEdges(
|
||||
edges: { id: string; familyId?: string; source: string; target: string; attributes: EdgeAttributes }[]
|
||||
): void {
|
||||
const touchedPairs = new Map<string, { source: string; target: string }>();
|
||||
|
||||
for (const { id, familyId, source, target, attributes } of edges) {
|
||||
if (source === target) continue; // skip self-loops; graph was created with allowSelfLoops: false
|
||||
const edgeId = String(attributes.edgeId || id);
|
||||
const resolvedFamilyId = String(attributes.familyId || familyId || edgeId);
|
||||
|
||||
if (graph.hasNode(source) && graph.hasNode(target)) {
|
||||
graph.mergeDirectedEdgeWithKey(edgeId, source, target, {
|
||||
...attributes,
|
||||
edgeId,
|
||||
familyId: resolvedFamilyId,
|
||||
sourceId: source,
|
||||
targetId: target,
|
||||
});
|
||||
touchedPairs.set(pairRegistryKey(source, target), { source, target });
|
||||
}
|
||||
}
|
||||
|
||||
touchedPairs.forEach(({ source, target }) => {
|
||||
normalizeParallelMetadataForPair(source, target);
|
||||
});
|
||||
}
|
||||
|
||||
export function clearGraph(): void {
|
||||
graph.clear();
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
declare module 'vis-timeline/standalone' {
|
||||
export class Timeline {
|
||||
constructor(container: HTMLElement, items: any, options?: any);
|
||||
on(event: string, callback: (properties: any) => void): void;
|
||||
destroy(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'vis-data/standalone' {
|
||||
export class DataSet<T = any> {
|
||||
constructor(data?: T[], options?: any);
|
||||
add(data: T | T[]): void;
|
||||
update(data: T | T[]): void;
|
||||
remove(id: string | number | (string | number)[]): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
.sem-workspace-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 18px;
|
||||
padding: 20px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 24%),
|
||||
linear-gradient(180deg, rgba(8, 17, 29, 0.94), rgba(3, 7, 14, 0.98));
|
||||
}
|
||||
|
||||
.sem-workspace-frame > :last-child {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.sem-workspace-hero {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
padding: 20px 22px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 18, 33, 0.88), rgba(11, 22, 38, 0.72)),
|
||||
radial-gradient(circle at top right, rgba(255, 179, 109, 0.08), transparent 28%);
|
||||
box-shadow: var(--shadow-strong), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.sem-workspace-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sem-workspace-kicker::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--accent-2), var(--warm));
|
||||
box-shadow: 0 0 14px rgba(158, 217, 255, 0.45);
|
||||
}
|
||||
|
||||
.sem-workspace-title {
|
||||
color: var(--text-1);
|
||||
font-size: 28px;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.05em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sem-workspace-subtitle {
|
||||
margin-top: 8px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: 58ch;
|
||||
}
|
||||
|
||||
.sem-workspace-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sem-grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.12fr) minmax(320px, 0.88fr);
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sem-grid-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sem-surface {
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 18, 32, 0.84), rgba(10, 18, 31, 0.72)),
|
||||
radial-gradient(circle at top, rgba(103, 182, 255, 0.05), transparent 34%);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.sem-surface--subtle {
|
||||
background: linear-gradient(180deg, rgba(9, 18, 32, 0.7), rgba(9, 16, 28, 0.52));
|
||||
}
|
||||
|
||||
.sem-surface--accent {
|
||||
border-color: var(--panel-border-strong);
|
||||
}
|
||||
|
||||
.sem-surface-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sem-surface-body--tight {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.sem-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sem-section-eyebrow {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sem-section-title {
|
||||
color: var(--text-1);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sem-section-copy {
|
||||
margin-top: 6px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sem-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(103, 182, 255, 0.16);
|
||||
background: rgba(103, 182, 255, 0.08);
|
||||
color: #9ed9ff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sem-chip--warm {
|
||||
color: #ffce97;
|
||||
border-color: rgba(255, 179, 109, 0.16);
|
||||
background: rgba(255, 179, 109, 0.08);
|
||||
}
|
||||
|
||||
.sem-chip--success {
|
||||
color: #8bf0bf;
|
||||
border-color: rgba(80, 210, 159, 0.16);
|
||||
background: rgba(80, 210, 159, 0.08);
|
||||
}
|
||||
|
||||
.sem-command-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(132, 197, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.sem-command-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sem-segmented {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(132, 197, 255, 0.12);
|
||||
background: rgba(8, 16, 28, 0.58);
|
||||
}
|
||||
|
||||
.sem-button,
|
||||
.sem-button-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.sem-button:hover,
|
||||
.sem-button-secondary:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sem-button {
|
||||
color: white;
|
||||
border: 1px solid rgba(103, 182, 255, 0.2);
|
||||
background: linear-gradient(180deg, rgba(53, 130, 245, 0.3), rgba(25, 88, 185, 0.18));
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sem-button-secondary {
|
||||
color: #d8e8fb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sem-button:disabled,
|
||||
.sem-button-secondary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.sem-segmented button {
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sem-segmented button[data-active="true"] {
|
||||
background: linear-gradient(180deg, rgba(53, 130, 245, 0.34), rgba(25, 88, 185, 0.2));
|
||||
border-color: rgba(132, 197, 255, 0.2);
|
||||
}
|
||||
|
||||
.sem-input,
|
||||
.sem-select,
|
||||
.sem-textarea {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(132, 197, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: var(--text-1);
|
||||
padding: 12px 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.sem-input::placeholder,
|
||||
.sem-textarea::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.sem-textarea {
|
||||
min-height: 160px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.sem-inspector {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sem-empty-state,
|
||||
.sem-loading-state {
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.sem-empty-state-title,
|
||||
.sem-loading-state-title {
|
||||
color: var(--text-1);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
|
||||
.sem-empty-state-copy,
|
||||
.sem-loading-state-copy {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.sem-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sem-list-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(132, 197, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
color: var(--text-1);
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sem-list-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(132, 197, 255, 0.18);
|
||||
background: rgba(103, 182, 255, 0.08);
|
||||
}
|
||||
|
||||
.sem-list-item[data-active="true"] {
|
||||
border-color: rgba(132, 197, 255, 0.22);
|
||||
background: linear-gradient(180deg, rgba(53, 130, 245, 0.18), rgba(25, 88, 185, 0.1));
|
||||
}
|
||||
|
||||
.sem-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.sem-table th {
|
||||
text-align: left;
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.sem-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.sem-grid-two,
|
||||
.sem-grid-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sem-workspace-hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sem-workspace-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
type SurfaceTone = "default" | "subtle" | "accent";
|
||||
type SurfacePadding = "default" | "tight" | "none";
|
||||
type ChipTone = "default" | "warm" | "success";
|
||||
|
||||
function cx(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function WorkspaceFrame({
|
||||
kicker,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
kicker?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-workspace-frame">
|
||||
<header className="sem-workspace-hero">
|
||||
<div>
|
||||
{kicker ? <div className="sem-workspace-kicker">{kicker}</div> : null}
|
||||
<h2 className="sem-workspace-title">{title}</h2>
|
||||
{subtitle ? <p className="sem-workspace-subtitle">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="sem-workspace-actions">{actions}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SurfaceCard({
|
||||
children,
|
||||
tone = "default",
|
||||
padding = "default",
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: SurfaceTone;
|
||||
padding?: SurfacePadding;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div className={cx("sem-surface", tone !== "default" && `sem-surface--${tone}`, className)} style={style}>
|
||||
{padding === "none" ? children : <div className={cx("sem-surface-body", padding === "tight" && "sem-surface-body--tight")}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-section-header">
|
||||
<div>
|
||||
{eyebrow ? <div className="sem-section-eyebrow">{eyebrow}</div> : null}
|
||||
<h3 className="sem-section-title">{title}</h3>
|
||||
{description ? <p className="sem-section-copy">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="sem-command-group">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricChip({
|
||||
children,
|
||||
tone = "default",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: ChipTone;
|
||||
}) {
|
||||
return <span className={cx("sem-chip", tone !== "default" && `sem-chip--${tone}`)}>{children}</span>;
|
||||
}
|
||||
|
||||
export function CommandBar({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-command-bar">
|
||||
<div className="sem-command-group">{left}</div>
|
||||
<div className="sem-command-group">{right}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InspectorPanel({
|
||||
children,
|
||||
open = true,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<SurfaceCard className={cx("sem-inspector", className)} padding="none" style={{ display: open ? "block" : "none" }}>
|
||||
{children}
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-empty-state">
|
||||
{icon}
|
||||
<div className="sem-empty-state-title">{title}</div>
|
||||
<div className="sem-empty-state-copy">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="sem-loading-state">
|
||||
<div className="animate-spin" style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "999px",
|
||||
border: "2px solid rgba(103, 182, 255, 0.18)",
|
||||
borderTopColor: "rgba(158, 217, 255, 0.92)",
|
||||
marginBottom: 12,
|
||||
}} />
|
||||
<div className="sem-loading-state-title">{title}</div>
|
||||
<div className="sem-loading-state-copy">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedControl({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <div className="sem-segmented">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
|
||||
function CausalChainNode({ hop, title, desc }: { hop: number, title: string, desc: string }) {
|
||||
return (
|
||||
<div style={{ marginLeft: hop * 24, paddingLeft: 16, borderLeft: "2px solid rgba(88,166,255,0.3)", position: "relative", marginBottom: 16 }}>
|
||||
<div style={{ position: "absolute", left: -6, top: 4, width: 10, height: 10, borderRadius: "50%", background: "#58a6ff", boxShadow: "0 0 8px #58a6ff" }} />
|
||||
<h4 style={{ margin: "0 0 4px 0", color: "#e6edf3", fontSize: 14 }}>{title}</h4>
|
||||
<p style={{ margin: 0, color: "#8b949e", fontSize: 13 }}>{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DecisionWorkspace() {
|
||||
const [decisions, setDecisions] = useState<any[]>([]);
|
||||
const [selectedDecision, setSelectedDecision] = useState<any | null>(null);
|
||||
const [chain, setChain] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/decisions")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setDecisions(data);
|
||||
if (data.length > 0) handleSelectDecision(data[0]);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleSelectDecision = async (d: any) => {
|
||||
setSelectedDecision(d);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/decisions/${d.decision_id}/chain`);
|
||||
const data = await res.json();
|
||||
setChain(data.chain || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: "100%", background: "#0d1117", overflow: "hidden" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Left Column: Decisions List */}
|
||||
<div className="glass-panel" style={{ width: 320, padding: 24, display: "flex", flexDirection: "column", gap: 16, borderRight: "1px solid rgba(88,166,255,0.2)", borderTop: "none", borderLeft: "none", borderBottom: "none", borderRadius: 0 }}>
|
||||
<h2 style={{ color: "#ffffff", margin: 0, fontSize: 18, borderBottom: "1px solid rgba(255,255,255,0.1)", paddingBottom: 12 }}>
|
||||
Decision Tree
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{decisions.map(d => (
|
||||
<button
|
||||
key={d.decision_id}
|
||||
onClick={() => handleSelectDecision(d)}
|
||||
style={{
|
||||
textAlign: "left", padding: "12px 16px", borderRadius: 8, cursor: "pointer",
|
||||
background: selectedDecision?.decision_id === d.decision_id ? "rgba(88,166,255,0.15)" : "transparent",
|
||||
border: `1px solid ${selectedDecision?.decision_id === d.decision_id ? "#58a6ff" : "rgba(255,255,255,0.1)"}`,
|
||||
color: selectedDecision?.decision_id === d.decision_id ? "#ffffff" : "#c9d1d9",
|
||||
transition: "all 0.2s"
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{d.decision_id}</div>
|
||||
<div style={{ fontSize: 12, color: "#8b949e", marginTop: 4 }}>{d.category || 'Uncategorized'}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Causal Chains */}
|
||||
<div style={{ flex: 1, padding: 32, overflowY: "auto", position: "relative" }}>
|
||||
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse at top right, rgba(88,166,255,0.05), transparent 60%)", pointerEvents: "none" }} />
|
||||
|
||||
{selectedDecision ? (
|
||||
<>
|
||||
<h1 style={{ color: "#ffffff", fontSize: 28, margin: "0 0 8px 0" }}>{selectedDecision.decision_id}</h1>
|
||||
<div style={{ color: "#58a6ff", fontSize: 14, marginBottom: 40 }}>Outcome: {selectedDecision.outcome}</div>
|
||||
|
||||
<div className="glass-panel" style={{ padding: 32, borderRadius: 12 }}>
|
||||
<h3 style={{ color: "#ffffff", margin: "0 0 24px 0", fontSize: 16 }}>Causal Chain</h3>
|
||||
{loading ? (
|
||||
<div style={{ color: "#8b949e" }}>Loading chain...</div>
|
||||
) : chain.length > 0 ? (
|
||||
chain.map((c, i) => (
|
||||
<CausalChainNode key={i} hop={i} title={`${c.relationship} ${c.id}`} desc={c.content || '...'} />
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: "#8b949e" }}>No causal chain found.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", textAlign: "center", marginTop: 100 }}>Select a decision to view details</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
export function DiffMergeWorkspace() {
|
||||
const [primaryId, setPrimaryId] = useState("n-primary-1");
|
||||
const [duplicateId, setDuplicateId] = useState("n-dup-2");
|
||||
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const handleMerge = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/enrich/merge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.merged_into) {
|
||||
setMsg(`Merge success: redirected ${data.edges_updated} edges to ${data.merged_into}`);
|
||||
} else {
|
||||
setMsg("Merge failed...");
|
||||
}
|
||||
} catch (err) {
|
||||
setMsg("Error calling merge endpoint.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 32, gap: 24, boxSizing: "border-box" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 8px 0", color: "#fff" }}>Entity Diff & Merge</h1>
|
||||
<p style={{ margin: 0, color: "#8b949e" }}>Compare suspected duplicate entities and reconcile them.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 24, flex: 1 }}>
|
||||
{/* Primary View */}
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, padding: 24 }}>
|
||||
<h3 style={{ color: "#58a6ff", margin: "0 0 16px 0", borderBottom: "1px solid rgba(88,166,255,0.2)", paddingBottom: 8 }}>Primary Entity</h3>
|
||||
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 13 }}>Primary Node ID</label>
|
||||
<input
|
||||
value={primaryId} onChange={e => setPrimaryId(e.target.value)}
|
||||
style={{ width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", color: "#fff", padding: "8px 12px", borderRadius: 6, marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<div style={{ background: "rgba(0,0,0,0.2)", padding: 16, borderRadius: 6 }}>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginBottom: 4 }}>Name</div>
|
||||
<div style={{ color: "#fff", fontSize: 14 }}>Sample Company Inc.</div>
|
||||
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 16, marginBottom: 4 }}>Founded</div>
|
||||
<div style={{ color: "#fff", fontSize: 14 }}>2004-05-12</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate View */}
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, padding: 24 }}>
|
||||
<h3 style={{ color: "#ff7b72", margin: "0 0 16px 0", borderBottom: "1px solid rgba(255,123,114,0.2)", paddingBottom: 8 }}>Duplicate Entity</h3>
|
||||
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 13 }}>Duplicate Node ID</label>
|
||||
<input
|
||||
value={duplicateId} onChange={e => setDuplicateId(e.target.value)}
|
||||
style={{ width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", color: "#fff", padding: "8px 12px", borderRadius: 6, marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<div style={{ background: "rgba(0,0,0,0.2)", padding: 16, borderRadius: 6 }}>
|
||||
<div style={{ color: "#d2a8ff", fontSize: 12, marginBottom: 4 }}>Name</div>
|
||||
{/* Amber highlight for differing values */}
|
||||
<div style={{ color: "#d29922", fontSize: 14, fontWeight: "bold" }}>Sample Company</div>
|
||||
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 16, marginBottom: 4 }}>Founded</div>
|
||||
<div style={{ color: "#fff", fontSize: 14 }}>2004-05-12</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ color: "#58a6ff" }}>{msg}</div>
|
||||
<button
|
||||
onClick={handleMerge}
|
||||
style={{ background: "#238636", color: "#fff", border: "none", padding: "10px 24px", borderRadius: 6, fontWeight: 600, cursor: "pointer", fontSize: 16 }}
|
||||
>
|
||||
Confirm Merge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
|
||||
export type LinkPrediction = {
|
||||
target: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type PathResponse = {
|
||||
path: string[];
|
||||
edge_ids?: string[];
|
||||
total_weight: number;
|
||||
};
|
||||
|
||||
export interface GraphInspectorPanelProps {
|
||||
nodeId: string;
|
||||
predictions: LinkPrediction[];
|
||||
predictionType: string;
|
||||
onPredictionTypeChange: (value: string) => void;
|
||||
onRunPredictions: () => void;
|
||||
pathTargetId: string;
|
||||
onPathTargetChange: (value: string) => void;
|
||||
onTracePath: () => void;
|
||||
pathResult: PathResponse | null;
|
||||
onDownloadProvenance: (format: "json" | "markdown") => void;
|
||||
}
|
||||
|
||||
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||
|
||||
function sourceAttribution(properties: Record<string, unknown>) {
|
||||
return PROVENANCE_KEYS
|
||||
.filter((key) => key in properties)
|
||||
.map((key) => ({ key, value: properties[key] }));
|
||||
}
|
||||
|
||||
export function GraphInspectorPanel({
|
||||
nodeId,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
onRunPredictions,
|
||||
pathTargetId,
|
||||
onPathTargetChange,
|
||||
onTracePath,
|
||||
pathResult,
|
||||
onDownloadProvenance,
|
||||
}: GraphInspectorPanelProps) {
|
||||
if (!nodeId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: "center" }}>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0 }}>
|
||||
Search for a node or click one in the canvas to inspect its properties.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const attributes = graph.getNodeAttributes(nodeId) as {
|
||||
color?: string;
|
||||
content?: string;
|
||||
label?: string;
|
||||
nodeType?: string;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
const properties = attributes?.properties ?? {};
|
||||
const attribution = sourceAttribution(properties);
|
||||
const accentColor = attributes?.color || "#58a6ff";
|
||||
const propertyEntries = Object.entries(properties).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"x",
|
||||
"y",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
"content",
|
||||
"source",
|
||||
"source_url",
|
||||
"pmid",
|
||||
"pmids",
|
||||
"evidence",
|
||||
"provenance",
|
||||
"confidence",
|
||||
].includes(key),
|
||||
);
|
||||
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
background: accentColor,
|
||||
boxShadow: `0 0 10px ${accentColor}`,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? nodeId)}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6 }}>{nodeId}</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||
{attributes?.valid_from || attributes?.valid_until ? (
|
||||
<span style={subtleChipStyle}>temporal</span>
|
||||
) : null}
|
||||
{attribution.length ? <span style={subtleChipStyle}>{attribution.length} source fields</span> : null}
|
||||
{predictions.length ? <span style={subtleChipStyle}>{predictions.length} candidate links</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(attributes?.valid_from || attributes?.valid_until) && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "#79c0ff",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
|
||||
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Actions</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<button style={{ ...actionButtonStyle, width: "100%", justifyContent: "center" }} onClick={onRunPredictions}>
|
||||
Run Link Prediction
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
|
||||
Provenance JSON
|
||||
</button>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>
|
||||
Provenance MD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
value={predictionType}
|
||||
onChange={(event) => onPredictionTypeChange(event.target.value)}
|
||||
placeholder="Optional candidate type filter, e.g. disease"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Trace Path</div>
|
||||
<input
|
||||
value={pathTargetId}
|
||||
onChange={(event) => onPathTargetChange(event.target.value)}
|
||||
placeholder="Target node ID"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||
{pathResult?.path?.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
|
||||
{pathResult.path.map((step, index) => (
|
||||
<div key={`${step}-${index}`} style={pathStepStyle}>{index + 1}. {step}</div>
|
||||
))}
|
||||
<div style={{ color: "#79c0ff", fontSize: 12, marginTop: 4 }}>
|
||||
total weight: {pathResult.total_weight.toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Choose a target or click a candidate prediction to prepare a path trace.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details className="node-panel-collapse" open={predictions.length > 0}>
|
||||
<summary className="node-panel-summary">Candidate Links</summary>
|
||||
<div className="node-panel-body">
|
||||
{predictions.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{predictions.map((prediction) => (
|
||||
<button
|
||||
key={`${prediction.target}-${prediction.type}`}
|
||||
style={predictionCardStyle}
|
||||
onClick={() => onPathTargetChange(prediction.target)}
|
||||
>
|
||||
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>
|
||||
confidence {prediction.score.toFixed(3)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Run link prediction to surface likely next-hop relationships.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="node-panel-collapse">
|
||||
<summary className="node-panel-summary">Source Attribution</summary>
|
||||
<div className="node-panel-body">
|
||||
{attribution.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{attribution.map(({ key, value }) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No explicit attribution metadata was found on this node.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="node-panel-collapse">
|
||||
<summary className="node-panel-summary">Properties</summary>
|
||||
<div className="node-panel-body">
|
||||
{propertyEntries.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No additional properties are attached to this node.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
background: "rgba(4, 10, 18, 0.5)",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
color: "#edf5ff",
|
||||
borderRadius: 12,
|
||||
padding: "11px 13px",
|
||||
fontSize: 13,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||
};
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||
color: "#fff",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
borderRadius: 12,
|
||||
padding: "9px 12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: `0 8px 22px ${GRAPH_THEME.palette.background.shellGlow}`,
|
||||
};
|
||||
|
||||
const secondaryActionButtonStyle: CSSProperties = {
|
||||
...actionButtonStyle,
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
color: "#c6d4e3",
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const predictionCardStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: 12,
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.12)",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const pathStepStyle: CSSProperties = {
|
||||
color: "#e6edf3",
|
||||
fontSize: 13,
|
||||
padding: "8px 10px",
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const propertyCardStyle: CSSProperties = {
|
||||
background: "rgba(0, 0, 0, 0.2)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const subtleChipStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
color: "#9fb6d2",
|
||||
padding: "4px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
background: "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
borderRadius: 14,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import { GRAPH_LOAD_STAGE_SEQUENCE, createGraphLoadProgress, getGraphLoadStageLabel } from "./graphLoading";
|
||||
import type { GraphLoadProgress } from "./types";
|
||||
|
||||
const LOADING_OVERLAY_CSS = `
|
||||
.graph-stage-loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 220ms ease, transform 220ms ease;
|
||||
}
|
||||
.graph-stage-loader[data-exiting="true"] {
|
||||
opacity: 0;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
.graph-stage-loader-card {
|
||||
width: min(540px, calc(100% - 48px));
|
||||
border-radius: 24px;
|
||||
padding: 20px 20px 18px;
|
||||
border: 1px solid rgba(127, 208, 255, 0.18);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(242, 182, 109, 0.12), transparent 28%),
|
||||
radial-gradient(circle at top left, rgba(127, 208, 255, 0.14), transparent 30%),
|
||||
linear-gradient(145deg, rgba(7, 17, 31, 0.94), rgba(12, 25, 43, 0.82));
|
||||
box-shadow: 0 26px 90px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(18px) saturate(1.08);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.08);
|
||||
}
|
||||
.graph-stage-loader-card[data-live="true"] {
|
||||
width: min(500px, calc(100% - 56px));
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(242, 182, 109, 0.08), transparent 26%),
|
||||
radial-gradient(circle at top left, rgba(127, 208, 255, 0.12), transparent 28%),
|
||||
linear-gradient(145deg, rgba(7, 17, 31, 0.84), rgba(11, 24, 40, 0.72));
|
||||
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
.graph-stage-loader-beacon {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(127, 208, 255, 0.98), rgba(242, 182, 109, 0.94));
|
||||
box-shadow: 0 0 18px rgba(127, 208, 255, 0.4);
|
||||
}
|
||||
.graph-stage-loader-beacon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -7px;
|
||||
border-radius: inherit;
|
||||
border: 1px solid rgba(127, 208, 255, 0.18);
|
||||
animation: graph-loader-beacon 1.9s ease-out infinite;
|
||||
}
|
||||
.graph-stage-loader-track {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.graph-stage-loader-step {
|
||||
border-radius: 999px;
|
||||
padding: 7px 0;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid rgba(127, 208, 255, 0.08);
|
||||
color: rgba(143, 168, 198, 0.72);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.graph-stage-loader-step[data-state="done"] {
|
||||
color: rgba(214, 232, 250, 0.92);
|
||||
border-color: rgba(127, 208, 255, 0.18);
|
||||
background: rgba(89, 155, 220, 0.14);
|
||||
}
|
||||
.graph-stage-loader-step[data-state="active"] {
|
||||
color: #eff7ff;
|
||||
border-color: rgba(242, 182, 109, 0.24);
|
||||
background: linear-gradient(135deg, rgba(49, 108, 172, 0.28), rgba(242, 182, 109, 0.16));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.graph-stage-loader-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(127, 208, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.graph-stage-loader-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgba(74, 163, 255, 0.9), rgba(127, 208, 255, 0.96), rgba(242, 182, 109, 0.92));
|
||||
box-shadow: 0 0 30px rgba(74, 163, 255, 0.28);
|
||||
transition: width 220ms ease;
|
||||
}
|
||||
.graph-stage-loader-bar-indeterminate::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
width: 34%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(74, 163, 255, 0), rgba(127, 208, 255, 0.94), rgba(242, 182, 109, 0.82), rgba(74, 163, 255, 0));
|
||||
box-shadow: 0 0 26px rgba(127, 208, 255, 0.18);
|
||||
animation: graph-loader-sweep 1.5s cubic-bezier(0.22, 1, 0.36, 1) infinite;
|
||||
}
|
||||
@keyframes graph-loader-beacon {
|
||||
0% { transform: scale(0.72); opacity: 0.6; }
|
||||
100% { transform: scale(1.44); opacity: 0; }
|
||||
}
|
||||
@keyframes graph-loader-sweep {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(360%); }
|
||||
}
|
||||
`;
|
||||
|
||||
function formatLayoutSource(source: GraphLoadProgress["layoutSource"]) {
|
||||
switch (source) {
|
||||
case "provided":
|
||||
return "Persisted layout";
|
||||
case "carried":
|
||||
return "Preserved layout";
|
||||
case "runtime":
|
||||
return "Runtime layout";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLayoutState(state: GraphLoadProgress["layoutState"]) {
|
||||
switch (state) {
|
||||
case "bootstrapping":
|
||||
return "Bootstrapping";
|
||||
case "running":
|
||||
return "Settling";
|
||||
case "interactive":
|
||||
return "Interactive";
|
||||
case "stabilized":
|
||||
return "Stable";
|
||||
case "failed":
|
||||
return "Fallback";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const loadingMetricStyle = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(127, 208, 255, 0.12)",
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
color: "#b8cade",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
} satisfies CSSProperties;
|
||||
|
||||
export function GraphLoadingOverlay({
|
||||
progress,
|
||||
visible,
|
||||
showGraphBehind,
|
||||
}: {
|
||||
progress: GraphLoadProgress | null;
|
||||
visible: boolean;
|
||||
showGraphBehind: boolean;
|
||||
}) {
|
||||
const [renderVisible, setRenderVisible] = useState(visible);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const [displayProgress, setDisplayProgress] = useState<GraphLoadProgress>(
|
||||
progress ?? createGraphLoadProgress({
|
||||
phase: "bootstrapping",
|
||||
message: "Preparing graph session",
|
||||
progressKind: "indeterminate",
|
||||
}),
|
||||
);
|
||||
const exitTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress) {
|
||||
setDisplayProgress(progress);
|
||||
}
|
||||
}, [progress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (exitTimerRef.current !== null) {
|
||||
window.clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
setRenderVisible(true);
|
||||
setExiting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExiting(true);
|
||||
exitTimerRef.current = window.setTimeout(() => {
|
||||
setRenderVisible(false);
|
||||
setExiting(false);
|
||||
exitTimerRef.current = null;
|
||||
}, 220);
|
||||
|
||||
return () => {
|
||||
if (exitTimerRef.current !== null) {
|
||||
window.clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [renderVisible, visible]);
|
||||
|
||||
if (!renderVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProgress = progress ?? displayProgress;
|
||||
const isLiveStage = activeProgress.phase === "stabilizing_layout" || activeProgress.showGraphBehind || showGraphBehind;
|
||||
const overlayBackground = isLiveStage
|
||||
? "linear-gradient(180deg, rgba(1,4,9,0.04), rgba(1,4,9,0.18))"
|
||||
: "linear-gradient(180deg, rgba(1,4,9,0.22), rgba(1,4,9,0.5))";
|
||||
const determinateRatio = activeProgress.progressKind === "determinate" && activeProgress.total
|
||||
? Math.max(0.05, Math.min(activeProgress.loaded ?? 0, activeProgress.total) / Math.max(activeProgress.total, 1))
|
||||
: null;
|
||||
const layoutSource = formatLayoutSource(activeProgress.layoutSource);
|
||||
const layoutState = formatLayoutState(activeProgress.layoutState);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="graph-stage-loader"
|
||||
data-exiting={exiting}
|
||||
style={{ background: overlayBackground }}
|
||||
>
|
||||
<style>{LOADING_OVERLAY_CSS}</style>
|
||||
<div className="graph-stage-loader-card" data-live={isLiveStage}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 14, marginBottom: 14 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: "#ffffff", fontSize: 20, fontWeight: 700, letterSpacing: "-0.03em", marginBottom: 6 }}>
|
||||
{activeProgress.title}
|
||||
</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.5 }}>
|
||||
{activeProgress.message}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
|
||||
<div className="graph-stage-loader-beacon" aria-hidden="true" />
|
||||
<div style={{ color: "#d7e9fb", fontSize: 11, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
Stage {activeProgress.stageIndex ?? 1}/{activeProgress.stageCount ?? GRAPH_LOAD_STAGE_SEQUENCE.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="graph-stage-loader-track" style={{ marginBottom: 14 }}>
|
||||
{GRAPH_LOAD_STAGE_SEQUENCE.map((phase, index) => {
|
||||
const current = activeProgress.stageIndex ?? 1;
|
||||
const state = index + 1 < current ? "done" : index + 1 === current ? "active" : "upcoming";
|
||||
return (
|
||||
<div key={phase} className="graph-stage-loader-step" data-state={state}>
|
||||
{getGraphLoadStageLabel(phase)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", marginBottom: 8 }}>
|
||||
<div style={{ color: "#dce9f6", fontSize: 12, fontWeight: 600 }}>
|
||||
{activeProgress.progressKind === "determinate" && activeProgress.total
|
||||
? `${(activeProgress.loaded ?? 0).toLocaleString()} / ${activeProgress.total.toLocaleString()} in current stage`
|
||||
: "Working through this stage"}
|
||||
</div>
|
||||
<div style={{ color: "#90a8c5", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
{activeProgress.progressKind === "determinate" && determinateRatio !== null
|
||||
? `${Math.round(determinateRatio * 100)}%`
|
||||
: "Live"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`graph-stage-loader-bar ${activeProgress.progressKind === "indeterminate" ? "graph-stage-loader-bar-indeterminate" : ""}`}>
|
||||
{activeProgress.progressKind === "determinate" && determinateRatio !== null ? (
|
||||
<span className="graph-stage-loader-bar-fill" style={{ width: `${Math.round(determinateRatio * 100)}%` }} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 14 }}>
|
||||
<span style={loadingMetricStyle}>
|
||||
{activeProgress.nodesLoaded.toLocaleString()}
|
||||
{activeProgress.nodesTotal ? ` / ${activeProgress.nodesTotal.toLocaleString()}` : ""} nodes
|
||||
</span>
|
||||
<span style={loadingMetricStyle}>
|
||||
{activeProgress.edgesLoaded.toLocaleString()}
|
||||
{activeProgress.edgesTotal ? ` / ${activeProgress.edgesTotal.toLocaleString()}` : ""} relationships
|
||||
</span>
|
||||
{layoutSource ? (
|
||||
<span style={{ ...loadingMetricStyle, color: "#a9ddff", borderColor: withAlpha(GRAPH_THEME.palette.accent.hovered, 0.22) }}>
|
||||
{layoutSource}
|
||||
{layoutState ? ` · ${layoutState}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import {
|
||||
chooseColorAccessor,
|
||||
colorForNodeKey,
|
||||
computeDegreeMap,
|
||||
computeEdgeSize,
|
||||
computeNodeSize,
|
||||
computePageRank,
|
||||
deterministicPosition,
|
||||
} from "./graphAnalytics";
|
||||
import { GRAPH_THEME } from "./graphConfig";
|
||||
import type { GraphSceneHandle } from "./scene";
|
||||
import type {
|
||||
GraphDataSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphLayoutSource,
|
||||
GraphLayoutStatus,
|
||||
GraphLoadProgress,
|
||||
GraphPath,
|
||||
GraphSelectedNodeState,
|
||||
GraphStageHandle,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
const STAGE_EFFECTS_STATE: GraphEffectsState = {
|
||||
pathPulseEnabled: false,
|
||||
pathFlowEnabled: false,
|
||||
lensEnabled: false,
|
||||
temporalEmphasisEnabled: false,
|
||||
semanticRegionsEnabled: false,
|
||||
contoursEnabled: false,
|
||||
pathfindingEnabled: false,
|
||||
communitiesEnabled: false,
|
||||
centralityEnabled: false,
|
||||
legendEnabled: false,
|
||||
diagnosticsEnabled: false,
|
||||
lensMode: "neighborhood",
|
||||
effectQuality: "bounded",
|
||||
};
|
||||
|
||||
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
|
||||
|
||||
function yieldToMain(): Promise<void> {
|
||||
if ("scheduler" in window && typeof (window as Window & { scheduler?: { yield?: () => Promise<void> } }).scheduler?.yield === "function") {
|
||||
return (window as Window & { scheduler: { yield: () => Promise<void> } }).scheduler.yield();
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
if (!nodeId || !graph.hasNode(nodeId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attributes = graph.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
return {
|
||||
id: nodeId,
|
||||
label: String(attributes.label || nodeId),
|
||||
content: String(attributes.content || attributes.label || nodeId),
|
||||
nodeType: attributes.nodeType || "entity",
|
||||
color: attributes.color,
|
||||
valid_from: attributes.valid_from ?? null,
|
||||
valid_until: attributes.valid_until ?? null,
|
||||
properties: attributes.properties ?? {},
|
||||
neighborCount: graph.neighbors(nodeId).length,
|
||||
};
|
||||
}
|
||||
|
||||
function hasUsableCoordinate(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
interface GraphRuntimeStageProps {
|
||||
snapshot: GraphDataSnapshot | null | undefined;
|
||||
selectedNodeId: string;
|
||||
activePath: GraphPath;
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
onSelectedNodeStateChange: (state: GraphSelectedNodeState | null) => void;
|
||||
isLayoutRunning: boolean;
|
||||
onLayoutRunningChange: (running: boolean) => void;
|
||||
viewMode: GraphViewMode;
|
||||
temporalTime: Date | null;
|
||||
onActiveNodeCountChange: (count: number | null) => void;
|
||||
onProgressChange: (progress: GraphLoadProgress | null) => void;
|
||||
onLayoutStatusChange: (status: GraphLayoutStatus) => void;
|
||||
onRuntimeReady: () => void;
|
||||
}
|
||||
|
||||
export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageProps>(
|
||||
function GraphRuntimeStage(
|
||||
{
|
||||
snapshot,
|
||||
selectedNodeId,
|
||||
activePath,
|
||||
onNodeSelect,
|
||||
onSelectedNodeStateChange,
|
||||
isLayoutRunning,
|
||||
onLayoutRunningChange,
|
||||
viewMode,
|
||||
temporalTime,
|
||||
onActiveNodeCountChange,
|
||||
onProgressChange,
|
||||
onLayoutStatusChange,
|
||||
onRuntimeReady,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const sceneRef = useRef<GraphSceneHandle>(null);
|
||||
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
||||
const [graphVersion, setGraphVersion] = useState(0);
|
||||
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
|
||||
|
||||
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
fitView: () => sceneRef.current?.fitView(),
|
||||
focusNode: (nodeId: string) => sceneRef.current?.focusNode(nodeId),
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function hydrateSnapshot() {
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
onProgressChange(createGraphLoadProgress({
|
||||
phase: "computing_styling",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: snapshot.summary.nodeCount,
|
||||
nodesTotal: snapshot.summary.nodeCount,
|
||||
edgesLoaded: snapshot.summary.edgeCount,
|
||||
edgesTotal: snapshot.summary.edgeCount,
|
||||
message: "Computing runtime graph styling",
|
||||
showGraphBehind: false,
|
||||
}));
|
||||
|
||||
const degreeByNode = computeDegreeMap(snapshot.nodes, snapshot.edges);
|
||||
const pageRankByNode = computePageRank(snapshot.nodes, snapshot.edges);
|
||||
const nodeIndexById = new Map(snapshot.nodes.map((node, index) => [node.id, index]));
|
||||
const previousPositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
const raw = attributes as Partial<NodeAttributes>;
|
||||
const x = Number(raw.x);
|
||||
const y = Number(raw.y);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||
previousPositions.set(nodeId, { x, y });
|
||||
}
|
||||
});
|
||||
|
||||
let explicitCoordinateCount = 0;
|
||||
let carriedCoordinateCount = 0;
|
||||
const draftAttributes = snapshot.nodes.map((node) => {
|
||||
const previousPosition = previousPositions.get(node.id);
|
||||
const position = hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)
|
||||
? { x: node.x, y: node.y }
|
||||
: previousPosition
|
||||
? previousPosition
|
||||
: deterministicPosition(node.id, nodeIndexById.get(node.id) ?? 0, snapshot.nodes.length);
|
||||
|
||||
if (hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)) {
|
||||
explicitCoordinateCount += 1;
|
||||
} else if (previousPosition) {
|
||||
carriedCoordinateCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
attributes: {
|
||||
label: node.content || node.id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
nodeType: node.type,
|
||||
content: node.content,
|
||||
valid_from: node.valid_from,
|
||||
valid_until: node.valid_until,
|
||||
properties: node.properties,
|
||||
} as NodeAttributes,
|
||||
};
|
||||
});
|
||||
|
||||
const layoutSource: GraphLayoutSource = explicitCoordinateCount > 0
|
||||
? "provided"
|
||||
: carriedCoordinateCount > 0
|
||||
? "carried"
|
||||
: "runtime";
|
||||
const hasCoordinates = explicitCoordinateCount > 0 || carriedCoordinateCount > 0;
|
||||
setRuntimeLayoutSource(layoutSource);
|
||||
|
||||
const colorAccessor = chooseColorAccessor(draftAttributes);
|
||||
await yieldToMain();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onProgressChange(createGraphLoadProgress({
|
||||
phase: "hydrating_scene",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: snapshot.summary.nodeCount,
|
||||
nodesTotal: snapshot.summary.nodeCount,
|
||||
edgesLoaded: snapshot.summary.edgeCount,
|
||||
edgesTotal: snapshot.summary.edgeCount,
|
||||
message: "Hydrating graph scene and renderer",
|
||||
showGraphBehind: false,
|
||||
}));
|
||||
|
||||
const nodesToMerge = draftAttributes.map(({ id, attributes }) => {
|
||||
const colorKey = colorAccessor(id, attributes);
|
||||
const baseColor = colorForNodeKey(colorKey);
|
||||
const dynamicSize = computeNodeSize(id, degreeByNode, pageRankByNode);
|
||||
return {
|
||||
id,
|
||||
attributes: {
|
||||
...attributes,
|
||||
color: baseColor,
|
||||
baseColor,
|
||||
size: dynamicSize,
|
||||
baseSize: dynamicSize,
|
||||
degree: degreeByNode.get(id) ?? 0,
|
||||
pageRank: pageRankByNode.get(id) ?? 0,
|
||||
glowColor: baseColor,
|
||||
borderColor: GRAPH_THEME.nodes.border,
|
||||
borderSize: 1,
|
||||
} as NodeAttributes,
|
||||
};
|
||||
});
|
||||
|
||||
const edgesToMerge = snapshot.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
familyId: edge.familyId,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
attributes: {
|
||||
edgeId: edge.id,
|
||||
familyId: edge.familyId,
|
||||
sourceId: edge.source,
|
||||
targetId: edge.target,
|
||||
weight: edge.weight,
|
||||
edgeType: edge.type,
|
||||
properties: edge.properties,
|
||||
size: computeEdgeSize(edge.weight),
|
||||
baseSize: computeEdgeSize(edge.weight),
|
||||
color: GRAPH_THEME.edges.baseColor,
|
||||
baseColor: GRAPH_THEME.edges.baseColor,
|
||||
} as EdgeAttributes,
|
||||
}));
|
||||
|
||||
clearGraph();
|
||||
batchMergeNodes(nodesToMerge);
|
||||
batchMergeEdges(edgesToMerge);
|
||||
prevActiveIdsRef.current = new Set(snapshot.nodes.map((node) => node.id));
|
||||
|
||||
await yieldToMain();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLayoutStatusChange({
|
||||
state: layoutSource === "runtime" ? "bootstrapping" : "interactive",
|
||||
source: layoutSource,
|
||||
hasCoordinates,
|
||||
layoutReady: layoutSource !== "runtime",
|
||||
displacement: null,
|
||||
elapsedMs: 0,
|
||||
stableSamples: 0,
|
||||
});
|
||||
|
||||
onLayoutRunningChange(layoutSource === "runtime");
|
||||
if (selectedNodeId) {
|
||||
sceneRef.current?.focusNode(selectedNodeId);
|
||||
} else {
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
setGraphVersion((current) => current + 1);
|
||||
if (layoutSource !== "runtime") {
|
||||
onProgressChange(null);
|
||||
} else {
|
||||
onProgressChange(createGraphLoadProgress({
|
||||
phase: "stabilizing_layout",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: snapshot.summary.nodeCount,
|
||||
nodesTotal: snapshot.summary.nodeCount,
|
||||
edgesLoaded: snapshot.summary.edgeCount,
|
||||
edgesTotal: snapshot.summary.edgeCount,
|
||||
message: "Settling runtime layout",
|
||||
showGraphBehind: true,
|
||||
layoutSource,
|
||||
layoutState: "bootstrapping",
|
||||
}));
|
||||
}
|
||||
onRuntimeReady();
|
||||
}
|
||||
|
||||
void hydrateSnapshot();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onLayoutRunningChange, onLayoutStatusChange, onProgressChange, onRuntimeReady, selectedNodeId, snapshot, stageSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNodeId) {
|
||||
onSelectedNodeStateChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectedNodeStateChange(buildSelectedNodeState(selectedNodeId));
|
||||
}, [graphVersion, onSelectedNodeStateChange, selectedNodeId, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot || !temporalTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const applySnapshot = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/temporal/snapshot?at=${encodeURIComponent(temporalTime.toISOString())}`);
|
||||
if (!response.ok || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: { active_node_ids: string[]; active_node_count: number } = await response.json();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextActiveIds = new Set(data.active_node_ids);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = prevActiveIdsRef.current;
|
||||
previous.forEach((id) => {
|
||||
if (!nextActiveIds.has(id) && graph.hasNode(id)) {
|
||||
graph.setNodeAttribute(id, "hidden", true);
|
||||
}
|
||||
});
|
||||
nextActiveIds.forEach((id) => {
|
||||
if (graph.hasNode(id)) {
|
||||
graph.setNodeAttribute(id, "hidden", false);
|
||||
}
|
||||
});
|
||||
|
||||
prevActiveIdsRef.current = nextActiveIds;
|
||||
onActiveNodeCountChange(data.active_node_count);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("[GraphRuntimeStage] temporal snapshot failed", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void applySnapshot();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onActiveNodeCountChange, snapshot, temporalTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = new WebSocket(`${socketProtocol()}//${window.location.host}/ws/graph-updates`);
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === "connection_ack" || message.event !== "graph_mutation") {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventType = message.data?.event_type;
|
||||
const payload = message.data?.payload;
|
||||
if (eventType === "ADD_NODE" && payload?.id) {
|
||||
batchMergeNodes([
|
||||
{
|
||||
id: payload.id,
|
||||
attributes: {
|
||||
label: payload.properties?.content || payload.id,
|
||||
x: Number.isFinite(Number(payload.x ?? payload.properties?.x))
|
||||
? Number(payload.x ?? payload.properties?.x)
|
||||
: deterministicPosition(payload.id, graph.order + 1, Math.max(graph.order + 1, 1)).x,
|
||||
y: Number.isFinite(Number(payload.y ?? payload.properties?.y))
|
||||
? Number(payload.y ?? payload.properties?.y)
|
||||
: deterministicPosition(payload.id, graph.order + 1, Math.max(graph.order + 1, 1)).y,
|
||||
nodeType: payload.type,
|
||||
content: payload.properties?.content || payload.id,
|
||||
valid_from: payload.properties?.valid_from ?? null,
|
||||
valid_until: payload.properties?.valid_until ?? null,
|
||||
properties: payload.properties || {},
|
||||
size: 8,
|
||||
color: colorForNodeKey(`${payload.type || "entity"}:${payload.id}`),
|
||||
baseColor: colorForNodeKey(`${payload.type || "entity"}:${payload.id}`),
|
||||
baseSize: 8,
|
||||
glowColor: colorForNodeKey(`${payload.type || "entity"}:${payload.id}`),
|
||||
borderColor: GRAPH_THEME.nodes.border,
|
||||
borderSize: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (eventType === "ADD_EDGE" && payload?.source_id && payload?.target_id) {
|
||||
batchMergeEdges([
|
||||
{
|
||||
id: String(payload.id),
|
||||
familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
|
||||
source: payload.source_id,
|
||||
target: payload.target_id,
|
||||
attributes: {
|
||||
edgeId: String(payload.id),
|
||||
familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
|
||||
sourceId: payload.source_id,
|
||||
targetId: payload.target_id,
|
||||
weight: Number(payload.weight ?? 1),
|
||||
edgeType: payload.type,
|
||||
properties: payload.properties || {},
|
||||
size: computeEdgeSize(Number(payload.weight ?? 1)),
|
||||
baseSize: computeEdgeSize(Number(payload.weight ?? 1)),
|
||||
color: payload.properties?.inferred ? GRAPH_THEME.edges.pathColor : GRAPH_THEME.edges.baseColor,
|
||||
baseColor: GRAPH_THEME.edges.baseColor,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
setGraphVersion((current) => current + 1);
|
||||
} catch (error) {
|
||||
console.error("[GraphRuntimeStage] websocket update failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SigmaSceneAdapter
|
||||
ref={sceneRef}
|
||||
onNodeSelect={onNodeSelect}
|
||||
selectedEdgeId=""
|
||||
selectedNodeId={selectedNodeId}
|
||||
activePath={activePath}
|
||||
effectsState={STAGE_EFFECTS_STATE}
|
||||
isLayoutRunning={isLayoutRunning}
|
||||
onLayoutRunningChange={onLayoutRunningChange}
|
||||
layoutSource={runtimeLayoutSource}
|
||||
onLayoutStatusChange={onLayoutStatusChange}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,821 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
||||
import { getGraphLoadTitle } from "./graphLoading";
|
||||
import { useGraphData, useReloadGraphData } from "./useGraphData";
|
||||
import type {
|
||||
ApiNode,
|
||||
GraphLayoutStatus,
|
||||
GraphLoadProgress,
|
||||
GraphPath,
|
||||
GraphSelectedNodeState,
|
||||
GraphStageHandle,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
type SearchResult = {
|
||||
node: {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
score: number;
|
||||
};
|
||||
|
||||
type LinkPrediction = {
|
||||
target: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type PathResponse = {
|
||||
path: GraphPath;
|
||||
total_weight: number;
|
||||
};
|
||||
|
||||
type TemporalBounds = {
|
||||
min?: string | null;
|
||||
max?: string | null;
|
||||
};
|
||||
|
||||
const GraphRuntimeStage = lazy(() =>
|
||||
import("./GraphRuntimeStage").then((module) => ({ default: module.GraphRuntimeStage })),
|
||||
);
|
||||
const TimelinePanel = lazy(() =>
|
||||
import("./TimelinePanel").then((module) => ({ default: module.TimelinePanel })),
|
||||
);
|
||||
|
||||
const HUD_CSS = `
|
||||
.palantir-bg {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(103, 182, 255, 0.1), transparent 24%),
|
||||
linear-gradient(180deg, #07111d 0%, #02060e 100%);
|
||||
}
|
||||
.palantir-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(88, 166, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(88, 166, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.78;
|
||||
}
|
||||
.palantir-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 34%, rgba(1, 4, 9, 0.88) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.hud-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.hud-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.hud-scrollbar::-webkit-scrollbar-thumb { background: rgba(88, 166, 255, 0.25); border-radius: 6px; }
|
||||
.graph-shell-top { position: absolute; top: 18px; left: 18px; right: 18px; z-index: 10; display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; pointer-events: none; }
|
||||
.graph-status-card, .graph-command-card {
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgba(132, 197, 255, 0.12);
|
||||
background: linear-gradient(180deg, rgba(7, 16, 29, 0.86), rgba(10, 22, 39, 0.72)), radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 50%);
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.graph-status-card { width: min(420px, 38vw); border-radius: 24px; padding: 16px 18px; }
|
||||
.graph-command-card { width: min(620px, 55vw); border-radius: 24px; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.graph-status-label { display: inline-flex; align-items: center; gap: 8px; color: rgba(160, 191, 223, 0.88); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; }
|
||||
.graph-status-label::before { content: ""; width: 7px; height: 7px; border-radius: 999px; background: linear-gradient(135deg, #8ed3ff, #ffb36a); box-shadow: 0 0 12px rgba(142, 211, 255, 0.5); }
|
||||
.graph-status-title { color: #eef5ff; font-size: 20px; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 6px; }
|
||||
.graph-status-copy { color: #8fa8c6; font-size: 12px; line-height: 1.55; margin-bottom: 14px; max-width: 40ch; }
|
||||
.graph-status-metrics, .graph-command-row, .graph-toggle-cluster, .graph-action-cluster { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.graph-command-row { justify-content: space-between; align-items: center; gap: 10px; }
|
||||
.graph-search-shell { flex: 1; min-width: 260px; display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-radius: 18px; border: 1px solid rgba(132, 197, 255, 0.12); background: rgba(0, 0, 0, 0.18); box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); }
|
||||
.graph-search-shell input { flex: 1; min-width: 0; border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; }
|
||||
.graph-search-shell input:focus { outline: none; }
|
||||
.graph-search-results { position: absolute; top: 120px; right: 18px; width: min(420px, calc(100vw - 132px)); max-height: 320px; overflow-y: auto; padding: 12px; border-radius: 20px; border: 1px solid rgba(132, 197, 255, 0.14); background: linear-gradient(180deg, rgba(8, 18, 33, 0.94), rgba(10, 21, 38, 0.86)); box-shadow: 0 18px 50px rgba(0,0,0,0.34); backdrop-filter: blur(18px); pointer-events: auto; z-index: 11; }
|
||||
.graph-search-results-label { color: #6f89ab; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; }
|
||||
.graph-search-result-card { width: 100%; text-align: left; padding: 12px 14px; border-radius: 16px; border: 1px solid rgba(132, 197, 255, 0.08); background: rgba(255, 255, 255, 0.025); cursor: pointer; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; }
|
||||
.graph-search-result-card:hover { transform: translateY(-1px); border-color: rgba(132, 197, 255, 0.18); background: rgba(103, 182, 255, 0.08); }
|
||||
.graph-inspector { pointer-events: auto; position: absolute; right: 18px; top: 154px; bottom: 108px; width: 380px; overflow-y: auto; transition: transform 0.34s cubic-bezier(0.16,1,0.3,1), opacity 0.22s ease; border-radius: 28px; border: 1px solid rgba(132, 197, 255, 0.14); background: linear-gradient(180deg, rgba(8, 18, 33, 0.9), rgba(6, 12, 22, 0.88)), radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 40%); box-shadow: -18px 0 48px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255,255,255,0.04); backdrop-filter: blur(20px); }
|
||||
.graph-inspector[data-open='false'] { transform: translateX(calc(100% + 24px)); opacity: 0; }
|
||||
@keyframes sem-loader-pulse {
|
||||
0%, 100% { transform: translateY(0) scale(0.92); opacity: 0.55; }
|
||||
50% { transform: translateY(-4px) scale(1.08); opacity: 1; }
|
||||
}
|
||||
@media (max-width: 1220px) {
|
||||
.graph-shell-top { flex-direction: column; align-items: stretch; }
|
||||
.graph-status-card, .graph-command-card { width: auto; }
|
||||
.graph-search-results { top: 202px; right: 18px; left: 18px; width: auto; }
|
||||
}
|
||||
`;
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [delay, value]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
function sourceAttribution(properties: Record<string, unknown>) {
|
||||
const keys = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"];
|
||||
return keys
|
||||
.filter((key) => key in properties)
|
||||
.map((key) => ({ key, value: properties[key] }));
|
||||
}
|
||||
|
||||
function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor = "#58a6ff"): GraphSelectedNodeState {
|
||||
return {
|
||||
id: node.id,
|
||||
label: node.content || node.id,
|
||||
content: node.content || node.id,
|
||||
nodeType: node.type,
|
||||
color: fallbackColor,
|
||||
valid_from: node.valid_from ?? null,
|
||||
valid_until: node.valid_until ?? null,
|
||||
properties: node.properties ?? {},
|
||||
neighborCount,
|
||||
};
|
||||
}
|
||||
|
||||
function TimelineFallback({ min, max }: TemporalBounds) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "90px",
|
||||
borderTop: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
background: "rgba(1, 4, 9, 0.88)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 18px",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span>Temporal scrubber</span>
|
||||
<span>{min || max ? "Preparing timeline runtime..." : "Temporal bounds loading..."}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodePanel({
|
||||
node,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
onRunPredictions,
|
||||
pathTargetId,
|
||||
onPathTargetChange,
|
||||
onTracePath,
|
||||
pathResult,
|
||||
onDownloadProvenance,
|
||||
}: {
|
||||
node: GraphSelectedNodeState | null;
|
||||
predictions: LinkPrediction[];
|
||||
predictionType: string;
|
||||
onPredictionTypeChange: (value: string) => void;
|
||||
onRunPredictions: () => void;
|
||||
pathTargetId: string;
|
||||
onPathTargetChange: (value: string) => void;
|
||||
onTracePath: () => void;
|
||||
pathResult: PathResponse | null;
|
||||
onDownloadProvenance: (format: "json" | "markdown") => void;
|
||||
}) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: "center" }}>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0 }}>
|
||||
Search for a node or click one in the canvas to inspect its properties.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const properties = node.properties ?? {};
|
||||
const attribution = sourceAttribution(properties);
|
||||
const accentColor = node.color || "#58a6ff";
|
||||
const propertyEntries = Object.entries(properties).filter(([key]) => !["x", "y", "valid_from", "valid_until", "content", "source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"].includes(key));
|
||||
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.14)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 800, textTransform: "uppercase", letterSpacing: "0.08em" }}>{node.nodeType || "Entity"}</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 24, lineHeight: 1, fontWeight: 800, letterSpacing: "-0.04em", wordBreak: "break-word" }}>{node.label}</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 8 }}>{node.id}</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||
{node.valid_from || node.valid_until ? <span style={subtleChipStyle}>temporal</span> : null}
|
||||
<span style={subtleChipStyle}>{node.neighborCount} neighbors</span>
|
||||
{attribution.length ? <span style={subtleChipStyle}>{attribution.length} source fields</span> : null}
|
||||
{predictions.length ? <span style={subtleChipStyle}>{predictions.length} candidate links</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Actions</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<button style={{ ...actionButtonStyle, width: "100%", justifyContent: "center" }} onClick={onRunPredictions}>Run Link Prediction</button>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>Provenance JSON</button>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>Provenance MD</button>
|
||||
</div>
|
||||
</div>
|
||||
<input value={predictionType} onChange={(event) => onPredictionTypeChange(event.target.value)} placeholder="Optional candidate type filter, e.g. disease" style={inputStyle} />
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Trace Path</div>
|
||||
<input value={pathTargetId} onChange={(event) => onPathTargetChange(event.target.value)} placeholder="Target node ID" style={inputStyle} />
|
||||
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||
{pathResult?.path?.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
|
||||
{pathResult.path.map((step, index) => (
|
||||
<div key={`${step}-${index}`} style={pathStepStyle}>{index + 1}. {step}</div>
|
||||
))}
|
||||
<div style={{ color: "#79c0ff", fontSize: 12, marginTop: 4 }}>total weight: {pathResult.total_weight.toFixed(3)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Choose a target or click a candidate prediction to prepare a path trace.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details style={collapseStyle} open={predictions.length > 0}>
|
||||
<summary style={summaryStyle}>Candidate Links</summary>
|
||||
<div style={{ padding: "0 14px 14px" }}>
|
||||
{predictions.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{predictions.map((prediction) => (
|
||||
<button key={`${prediction.target}-${prediction.type}`} style={predictionCardStyle} onClick={() => onPathTargetChange(prediction.target)}>
|
||||
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>confidence {prediction.score.toFixed(3)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Run link prediction to surface likely next-hop relationships.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details style={collapseStyle}>
|
||||
<summary style={summaryStyle}>Source Attribution</summary>
|
||||
<div style={{ padding: "0 14px 14px" }}>
|
||||
{attribution.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{attribution.map(({ key, value }) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>{typeof value === "object" ? JSON.stringify(value) : String(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No explicit attribution metadata was found on this node.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details style={collapseStyle}>
|
||||
<summary style={summaryStyle}>Properties</summary>
|
||||
<div style={{ padding: "0 14px 14px" }}>
|
||||
{propertyEntries.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>{typeof value === "object" ? JSON.stringify(value) : String(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No additional properties are attached to this node.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphWorkspaceShell() {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [selectedNodeState, setSelectedNodeState] = useState<GraphSelectedNodeState | null>(null);
|
||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchError, setSearchError] = useState("");
|
||||
const [predictionType, setPredictionType] = useState("");
|
||||
const [predictions, setPredictions] = useState<LinkPrediction[]>([]);
|
||||
const [pathTargetId, setPathTargetId] = useState("");
|
||||
const [pathResult, setPathResult] = useState<PathResponse | null>(null);
|
||||
const [activeNodeCount, setActiveNodeCount] = useState<number | null>(null);
|
||||
const [temporalBounds, setTemporalBounds] = useState<TemporalBounds | null>(null);
|
||||
const [scrubberTime, setScrubberTime] = useState<Date | null>(null);
|
||||
const [loadingProgress, setLoadingProgress] = useState<GraphLoadProgress | null>(null);
|
||||
const [isGraphStageReady, setIsGraphStageReady] = useState(false);
|
||||
const [layoutStatus, setLayoutStatus] = useState<GraphLayoutStatus>({
|
||||
state: "idle",
|
||||
source: "runtime",
|
||||
hasCoordinates: false,
|
||||
layoutReady: false,
|
||||
displacement: null,
|
||||
elapsedMs: 0,
|
||||
stableSamples: 0,
|
||||
});
|
||||
|
||||
const debouncedTime = useDebounce(scrubberTime, 150);
|
||||
const stageRef = useRef<GraphStageHandle>(null);
|
||||
const reload = useReloadGraphData();
|
||||
const { data: snapshot, isLoading, isFetching, isError, error } = useGraphData({ enabled: true, onProgress: setLoadingProgress });
|
||||
|
||||
const handleSelectedNodeStateChange = useCallback((state: GraphSelectedNodeState | null) => {
|
||||
setSelectedNodeState(state);
|
||||
}, []);
|
||||
|
||||
const handleLayoutRunningChange = useCallback((running: boolean) => {
|
||||
setIsLayoutRunning(running);
|
||||
}, []);
|
||||
|
||||
const handleActiveNodeCountChange = useCallback((count: number | null) => {
|
||||
setActiveNodeCount(count);
|
||||
}, []);
|
||||
|
||||
const handleProgressChange = useCallback((progress: GraphLoadProgress | null) => {
|
||||
setLoadingProgress(progress);
|
||||
}, []);
|
||||
|
||||
const handleRuntimeReady = useCallback(() => {
|
||||
setIsGraphStageReady(true);
|
||||
}, []);
|
||||
|
||||
const handleLayoutStatusChange = useCallback((status: GraphLayoutStatus) => {
|
||||
setLayoutStatus(status);
|
||||
if (status.layoutReady) {
|
||||
setLoadingProgress(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot) {
|
||||
setIsGraphStageReady(false);
|
||||
setActiveNodeCount(null);
|
||||
setLayoutStatus({
|
||||
state: snapshot.summary.layoutReady ? "interactive" : "idle",
|
||||
source: snapshot.summary.layoutSource ?? "runtime",
|
||||
hasCoordinates: snapshot.summary.hasCoordinates ?? false,
|
||||
layoutReady: snapshot.summary.layoutReady ?? false,
|
||||
displacement: null,
|
||||
elapsedMs: 0,
|
||||
stableSamples: 0,
|
||||
});
|
||||
}
|
||||
}, [snapshot?.fetchedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadBounds = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/temporal/bounds");
|
||||
if (!response.ok || cancelled) return;
|
||||
const data: TemporalBounds = await response.json();
|
||||
if (!cancelled) setTemporalBounds(data);
|
||||
} catch {
|
||||
if (!cancelled) setTemporalBounds(null);
|
||||
}
|
||||
};
|
||||
void loadBounds();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [snapshot?.summary.nodeCount, snapshot?.summary.edgeCount]);
|
||||
|
||||
const neighborCountMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
if (!snapshot) return map;
|
||||
for (const node of snapshot.nodes) map.set(node.id, 0);
|
||||
for (const edge of snapshot.edges) {
|
||||
map.set(edge.source, (map.get(edge.source) ?? 0) + 1);
|
||||
map.set(edge.target, (map.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}, [snapshot]);
|
||||
|
||||
const visibleSelectedNode = useMemo(() => {
|
||||
if (!selectedNodeId) return null;
|
||||
if (selectedNodeState?.id === selectedNodeId) return selectedNodeState;
|
||||
const snapshotNode = snapshot?.nodes.find((candidate) => candidate.id === selectedNodeId);
|
||||
if (snapshotNode) return toSelectedNodeState(snapshotNode, neighborCountMap.get(snapshotNode.id) ?? 0);
|
||||
const searchNode = searchResults.find((candidate) => candidate.node.id === selectedNodeId)?.node;
|
||||
return searchNode
|
||||
? {
|
||||
id: searchNode.id,
|
||||
label: searchNode.content || searchNode.id,
|
||||
content: searchNode.content || searchNode.id,
|
||||
nodeType: searchNode.type,
|
||||
color: "#58a6ff",
|
||||
valid_from: null,
|
||||
valid_until: null,
|
||||
properties: searchNode.properties ?? {},
|
||||
neighborCount: 0,
|
||||
}
|
||||
: null;
|
||||
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
setSelectedNodeId(nodeId);
|
||||
setPathResult(null);
|
||||
|
||||
if (!nodeId) {
|
||||
setSelectedNodeState(null);
|
||||
setPredictions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchResults([]);
|
||||
setIsLayoutRunning(false);
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchError("");
|
||||
try {
|
||||
const response = await fetch("/api/graph/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: searchQuery, limit: 8 }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSearchResults(data.results || []);
|
||||
if (data.results?.length) {
|
||||
focusNode(data.results[0].node.id);
|
||||
}
|
||||
} catch (searchFetchError) {
|
||||
setSearchError(searchFetchError instanceof Error ? searchFetchError.message : "Search failed");
|
||||
}
|
||||
}, [focusNode, searchQuery]);
|
||||
|
||||
const handleRunPredictions = useCallback(async () => {
|
||||
if (!selectedNodeId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/enrich/links", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
node_id: selectedNodeId,
|
||||
top_n: 6,
|
||||
candidate_type: predictionType || undefined,
|
||||
min_score: 0,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Link prediction failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setPredictions(data.predictions || []);
|
||||
} catch (predictionError) {
|
||||
console.error("[GraphWorkspaceShell] prediction failed", predictionError);
|
||||
setPredictions([]);
|
||||
}
|
||||
}, [predictionType, selectedNodeId]);
|
||||
|
||||
const handleTracePath = useCallback(async () => {
|
||||
if (!selectedNodeId || !pathTargetId.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data: PathResponse = await response.json();
|
||||
setPathResult(data);
|
||||
if (data.path?.length) {
|
||||
const lastStep = data.path[data.path.length - 1];
|
||||
stageRef.current?.focusNode(lastStep);
|
||||
}
|
||||
} catch (pathError) {
|
||||
console.error("[GraphWorkspaceShell] path trace failed", pathError);
|
||||
setPathResult(null);
|
||||
}
|
||||
}, [pathTargetId, selectedNodeId]);
|
||||
|
||||
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
|
||||
if (!selectedNodeId) return;
|
||||
|
||||
const suffix = format === "markdown" ? "markdown" : "json";
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(selectedNodeId)}&format=${suffix}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provenance report failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(anchor);
|
||||
}, [selectedNodeId]);
|
||||
|
||||
const searchSummary = useMemo(() => {
|
||||
if (!searchResults.length) return null;
|
||||
return `${searchResults.length} search result${searchResults.length === 1 ? "" : "s"}`;
|
||||
}, [searchResults.length]);
|
||||
|
||||
const focusedSummary = useMemo(() => {
|
||||
if (!visibleSelectedNode) return null;
|
||||
if (viewMode === "focused") {
|
||||
const visibleNeighbors = Math.min(visibleSelectedNode.neighborCount, 16);
|
||||
return `${visibleNeighbors + 1} nodes in focused view`;
|
||||
}
|
||||
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
|
||||
}, [viewMode, visibleSelectedNode]);
|
||||
|
||||
const showLoadingOverlay =
|
||||
isLoading
|
||||
|| isFetching
|
||||
|| !isGraphStageReady
|
||||
|| (layoutStatus.source === "runtime" && !layoutStatus.layoutReady && !selectedNodeId && viewMode === "full");
|
||||
|
||||
const layoutStatusLabel = useMemo(() => {
|
||||
if (layoutStatus.source === "provided" && layoutStatus.layoutReady) return "Persisted layout";
|
||||
if (layoutStatus.source === "carried" && layoutStatus.layoutReady) return "Preserved layout";
|
||||
if (layoutStatus.state === "bootstrapping") return "Bootstrapping layout";
|
||||
if (layoutStatus.state === "running") return "Stabilizing layout";
|
||||
if (layoutStatus.state === "failed") return "Layout timeout fallback";
|
||||
return null;
|
||||
}, [layoutStatus]);
|
||||
|
||||
return (
|
||||
<div className="palantir-bg" style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
<style>{HUD_CSS}</style>
|
||||
<div className="palantir-grid" />
|
||||
<div className="palantir-vignette" />
|
||||
|
||||
<div style={{ flex: 1, position: "relative", zIndex: 3, minHeight: 0 }}>
|
||||
<Suspense fallback={null}>
|
||||
<GraphRuntimeStage
|
||||
ref={stageRef}
|
||||
snapshot={snapshot}
|
||||
selectedNodeId={selectedNodeId}
|
||||
activePath={pathResult?.path ?? []}
|
||||
onNodeSelect={focusNode}
|
||||
onSelectedNodeStateChange={handleSelectedNodeStateChange}
|
||||
isLayoutRunning={isLayoutRunning}
|
||||
onLayoutRunningChange={handleLayoutRunningChange}
|
||||
viewMode={viewMode}
|
||||
temporalTime={debouncedTime}
|
||||
onActiveNodeCountChange={handleActiveNodeCountChange}
|
||||
onProgressChange={handleProgressChange}
|
||||
onLayoutStatusChange={handleLayoutStatusChange}
|
||||
onRuntimeReady={handleRuntimeReady}
|
||||
/>
|
||||
</Suspense>
|
||||
<GraphLoadingOverlay
|
||||
progress={loadingProgress}
|
||||
visible={showLoadingOverlay}
|
||||
showGraphBehind={Boolean(loadingProgress?.showGraphBehind || isGraphStageReady)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<TimelineFallback min={temporalBounds?.min ?? null} max={temporalBounds?.max ?? null} />}>
|
||||
<TimelinePanel
|
||||
onTimeChange={setScrubberTime}
|
||||
minDate={temporalBounds?.min ?? undefined}
|
||||
maxDate={temporalBounds?.max ?? undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<div style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 10 }}>
|
||||
<div className="graph-shell-top">
|
||||
<section className="graph-status-card">
|
||||
<div className="graph-status-label">Graph Studio</div>
|
||||
<div className="graph-status-title">{visibleSelectedNode ? visibleSelectedNode.label : "Knowledge Explorer"}</div>
|
||||
<div className="graph-status-metrics">
|
||||
{showLoadingOverlay && loadingProgress ? <span style={{ ...metricPillStyle, color: "#a9ddff" }}>{getGraphLoadTitle(loadingProgress.phase)}</span> : null}
|
||||
{layoutStatusLabel ? <span style={{ ...metricPillStyle, color: "#a9ddff" }}>{layoutStatusLabel}</span> : null}
|
||||
{snapshot ? <span style={metricPillStyle}>{snapshot.summary.nodeCount.toLocaleString()} nodes · {snapshot.summary.edgeCount.toLocaleString()} edges</span> : null}
|
||||
{activeNodeCount !== null ? <span style={{ ...metricPillStyle, color: "#4fd49c", borderColor: "rgba(79, 212, 156, 0.22)" }}>{activeNodeCount.toLocaleString()} active</span> : null}
|
||||
{searchSummary ? <span style={metricPillStyle}>{searchSummary}</span> : null}
|
||||
{focusedSummary ? <span style={{ ...metricPillStyle, color: "#f2b66d", borderColor: "rgba(242, 182, 109, 0.24)" }}>{focusedSummary}</span> : null}
|
||||
{isError ? <span style={{ ...metricPillStyle, color: "#ff8f85", borderColor: "rgba(255, 123, 114, 0.22)" }}>{(error as Error).message}</span> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="graph-command-card">
|
||||
<div className="graph-command-row">
|
||||
<div className="graph-toggle-cluster">
|
||||
{selectedNodeId ? (
|
||||
<>
|
||||
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
||||
<button onClick={() => setViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="graph-action-cluster">
|
||||
<button onClick={() => setIsLayoutRunning((value) => !value)} style={secondaryActionButtonStyle} disabled={isLoading || isFetching}>
|
||||
{isLayoutRunning ? "Pause Layout" : "Run Layout"}
|
||||
</button>
|
||||
<button onClick={() => { setIsGraphStageReady(false); reload(); }} style={secondaryActionButtonStyle} disabled={isLoading || isFetching}>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="graph-command-row">
|
||||
<div className="graph-search-shell">
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
void handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder="Search a node, e.g. Metformin"
|
||||
style={{ ...inputStyle, minWidth: 260 }}
|
||||
disabled={showLoadingOverlay && !selectedNodeId}
|
||||
/>
|
||||
<button onClick={() => void handleSearch()} style={actionButtonStyle} disabled={showLoadingOverlay && !selectedNodeId}>Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{searchError ? <div style={{ position: "absolute", top: 144, right: 34, color: "#ff7b72", fontSize: 12, pointerEvents: "auto" }}>{searchError}</div> : null}
|
||||
{searchResults.length ? (
|
||||
<div className="graph-search-results hud-scrollbar">
|
||||
<div className="graph-search-results-label">Search Results</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{searchResults.map((result) => (
|
||||
<button key={result.node.id} className="graph-search-result-card" onClick={() => focusNode(result.node.id)}>
|
||||
<div style={{ color: "#fff", fontWeight: 700 }}>{result.node.content || result.node.id}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{result.node.type}</div>
|
||||
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>score {result.score.toFixed(3)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="graph-inspector hud-scrollbar" data-open={selectedNodeId ? "true" : "false"}>
|
||||
<NodePanel
|
||||
node={visibleSelectedNode}
|
||||
predictions={predictions}
|
||||
predictionType={predictionType}
|
||||
onPredictionTypeChange={setPredictionType}
|
||||
onRunPredictions={() => void handleRunPredictions()}
|
||||
pathTargetId={pathTargetId}
|
||||
onPathTargetChange={setPathTargetId}
|
||||
onTracePath={() => void handleTracePath()}
|
||||
pathResult={pathResult}
|
||||
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metricPillStyle: CSSProperties = {
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
color: "#8ed3ff",
|
||||
padding: "6px 11px",
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
border: "1px solid rgba(88, 166, 255, 0.14)",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
background: "linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0.01))",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
borderRadius: 16,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
background: "rgba(0, 0, 0, 0.24)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.14)",
|
||||
color: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "10px 12px",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
background: "linear-gradient(180deg, rgba(53, 130, 245, 0.28), rgba(25, 88, 185, 0.18))",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
borderRadius: 12,
|
||||
padding: "10px 13px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const secondaryActionButtonStyle: CSSProperties = {
|
||||
...actionButtonStyle,
|
||||
background: "rgba(255, 255, 255, 0.035)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
color: "#d6e5f8",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const predictionCardStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: 12,
|
||||
background: "rgba(88, 166, 255, 0.06)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.1)",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const pathStepStyle: CSSProperties = {
|
||||
color: "#e6edf3",
|
||||
fontSize: 13,
|
||||
padding: "8px 10px",
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const propertyCardStyle: CSSProperties = {
|
||||
background: "rgba(0, 0, 0, 0.18)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const subtleChipStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.035)",
|
||||
color: "#9fb6d2",
|
||||
padding: "5px 9px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
};
|
||||
|
||||
const collapseStyle: CSSProperties = {
|
||||
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 14,
|
||||
background: "rgba(0, 0, 0, 0.14)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
listStyle: "none",
|
||||
padding: "12px 14px",
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import { GraphCanvas, type GraphCanvasHandle } from "./GraphCanvas";
|
||||
import type { GraphSceneAdapter, GraphSceneHandle, GraphSceneProps, GraphSceneRuntime } from "./scene";
|
||||
|
||||
export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
|
||||
function SigmaSceneAdapter(
|
||||
{
|
||||
onNodeSelect,
|
||||
onEdgeSelect,
|
||||
onInteractionStateChange,
|
||||
onCameraStateChange,
|
||||
onDiagnosticsChange,
|
||||
onAnalyticsChange,
|
||||
onRuntimeChange,
|
||||
onLayoutRunningChange,
|
||||
...sceneProps
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const canvasRef = useRef<GraphCanvasHandle>(null);
|
||||
const runtimeRef = useRef<GraphSceneRuntime | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
fitView: () => canvasRef.current?.fitView(),
|
||||
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
|
||||
getRuntime: () => runtimeRef.current,
|
||||
setLayoutRunning: onLayoutRunningChange
|
||||
? (running: boolean) => {
|
||||
onLayoutRunningChange(running);
|
||||
}
|
||||
: undefined,
|
||||
}), [onLayoutRunningChange]);
|
||||
|
||||
return (
|
||||
<GraphCanvas
|
||||
ref={canvasRef}
|
||||
onNodeClick={onNodeSelect ?? (() => {})}
|
||||
onEdgeClick={onEdgeSelect}
|
||||
onInteractionStateChange={onInteractionStateChange}
|
||||
onCameraStateChange={onCameraStateChange}
|
||||
onDiagnosticsChange={onDiagnosticsChange}
|
||||
onAnalyticsChange={onAnalyticsChange}
|
||||
onSceneRuntimeChange={(runtime) => {
|
||||
runtimeRef.current = runtime;
|
||||
onRuntimeChange?.(runtime);
|
||||
}}
|
||||
onLayoutRunningChange={onLayoutRunningChange}
|
||||
{...sceneProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
) as GraphSceneAdapter;
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { DataSet } from "vis-data";
|
||||
import { Timeline } from "vis-timeline";
|
||||
import type { TimelineOptions } from "vis-timeline";
|
||||
import "vis-timeline/styles/vis-timeline-graph2d.css";
|
||||
|
||||
export interface TimelinePanelProps {
|
||||
onTimeChange: (time: Date) => void;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_DATE = new Date("1970-01-01T00:00:00Z");
|
||||
const DEFAULT_MAX_DATE = new Date("2030-01-01T00:00:00Z");
|
||||
const PLAYHEAD_ID = "playhead";
|
||||
const PLAY_INTERVAL_MS = 500;
|
||||
const PLAY_STEP_MONTHS = 6;
|
||||
|
||||
const VIS_OVERRIDE_CSS = `
|
||||
.sem-timeline-wrap .vis-timeline { border: none !important; background: transparent !important; overflow: visible !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-background, .sem-timeline-wrap .vis-panel.vis-center { background: transparent !important; }
|
||||
.sem-timeline-wrap .vis-panel { border-color: rgba(88, 166, 255, 0.15) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text {
|
||||
color: #8b949e !important;
|
||||
font-size: 11px !important;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text.vis-major {
|
||||
color: #c9d1d9 !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: rgba(88, 166, 255, 0.07) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: rgba(88, 166, 255, 0.18) !important; }
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} {
|
||||
background: rgba(88, 166, 255, 0.15) !important;
|
||||
width: 2px !important;
|
||||
cursor: ew-resize !important;
|
||||
z-index: 5 !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} > .vis-custom-time-marker {
|
||||
background: #58a6ff !important;
|
||||
color: #0d1117 !important;
|
||||
font-size: 10px !important;
|
||||
font-weight: 700 !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 1px 5px !important;
|
||||
white-space: nowrap !important;
|
||||
box-shadow: 0 0 8px rgba(88, 166, 255, 0.7) !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-current-time { display: none !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
|
||||
`;
|
||||
|
||||
function safeDate(value: string | undefined, fallback: Date): Date {
|
||||
if (!value) return fallback;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function formatPlayheadLabel(value: Date): string {
|
||||
return `${value.getFullYear()}/${String(value.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<Timeline | null>(null);
|
||||
const playheadRef = useRef<Date>(DEFAULT_MIN_DATE);
|
||||
const playIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [displayDate, setDisplayDate] = useState(formatPlayheadLabel(DEFAULT_MIN_DATE));
|
||||
|
||||
const minBound = useMemo(() => safeDate(minDate, DEFAULT_MIN_DATE), [minDate]);
|
||||
const maxBound = useMemo(() => safeDate(maxDate, DEFAULT_MAX_DATE), [maxDate]);
|
||||
const defaultTime = useMemo(() => new Date(Math.round((minBound.getTime() + maxBound.getTime()) / 2)), [maxBound, minBound]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
const items = new DataSet([]);
|
||||
const options: TimelineOptions = {
|
||||
height: "100%",
|
||||
min: minBound,
|
||||
max: maxBound,
|
||||
start: minBound,
|
||||
end: maxBound,
|
||||
showCurrentTime: false,
|
||||
zoomable: true,
|
||||
moveable: true,
|
||||
zoomMin: 1000 * 60 * 60 * 24 * 365,
|
||||
zoomMax: 1000 * 60 * 60 * 24 * 365 * 80,
|
||||
showMajorLabels: true,
|
||||
showMinorLabels: true,
|
||||
timeAxis: { scale: "year", step: 5 },
|
||||
format: { minorLabels: { year: "YYYY" }, majorLabels: { year: "YYYY" } },
|
||||
orientation: { axis: "bottom" },
|
||||
margin: { item: 0, axis: 0 },
|
||||
selectable: false,
|
||||
stack: false,
|
||||
} as TimelineOptions;
|
||||
|
||||
const nextTimeline = new Timeline(containerRef.current, items, options);
|
||||
timelineRef.current = nextTimeline;
|
||||
playheadRef.current = defaultTime;
|
||||
nextTimeline.addCustomTime(defaultTime, PLAYHEAD_ID);
|
||||
nextTimeline.on("timechange", (props: { id: string; time: Date }) => {
|
||||
if (props.id !== PLAYHEAD_ID) return;
|
||||
playheadRef.current = props.time;
|
||||
nextTimeline.setCustomTime(props.time, PLAYHEAD_ID);
|
||||
onTimeChange(props.time);
|
||||
setDisplayDate(formatPlayheadLabel(props.time));
|
||||
});
|
||||
onTimeChange(defaultTime);
|
||||
setDisplayDate(formatPlayheadLabel(defaultTime));
|
||||
return () => {
|
||||
nextTimeline.destroy();
|
||||
timelineRef.current = null;
|
||||
};
|
||||
}
|
||||
|
||||
timeline.setOptions({ min: minBound, max: maxBound, start: minBound, end: maxBound });
|
||||
playheadRef.current = defaultTime;
|
||||
timeline.setCustomTime(defaultTime, PLAYHEAD_ID);
|
||||
onTimeChange(defaultTime);
|
||||
setDisplayDate(formatPlayheadLabel(defaultTime));
|
||||
}, [defaultTime, maxBound, minBound, onTimeChange]);
|
||||
|
||||
const startPlay = useCallback(() => {
|
||||
if (playIntervalRef.current) return;
|
||||
playIntervalRef.current = setInterval(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) return;
|
||||
const next = new Date(playheadRef.current);
|
||||
next.setMonth(next.getMonth() + PLAY_STEP_MONTHS);
|
||||
if (next >= maxBound) {
|
||||
next.setTime(minBound.getTime());
|
||||
}
|
||||
playheadRef.current = next;
|
||||
timeline.setCustomTime(next, PLAYHEAD_ID);
|
||||
onTimeChange(next);
|
||||
setDisplayDate(formatPlayheadLabel(next));
|
||||
}, PLAY_INTERVAL_MS);
|
||||
}, [maxBound, minBound, onTimeChange]);
|
||||
|
||||
const stopPlay = useCallback(() => {
|
||||
if (playIntervalRef.current) {
|
||||
clearInterval(playIntervalRef.current);
|
||||
playIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
setIsPlaying((previous) => {
|
||||
if (previous) {
|
||||
stopPlay();
|
||||
return false;
|
||||
}
|
||||
startPlay();
|
||||
return true;
|
||||
});
|
||||
}, [startPlay, stopPlay]);
|
||||
|
||||
useEffect(() => () => stopPlay(), [stopPlay]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: "1px solid rgba(88, 166, 255, 0.2)", background: "rgba(1, 4, 9, 0.88)", backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
|
||||
<style>{VIS_OVERRIDE_CSS}</style>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: "1px solid rgba(88, 166, 255, 0.15)", minWidth: 80, flexShrink: 0 }}>
|
||||
<button
|
||||
id="temporal-play-btn"
|
||||
onClick={togglePlay}
|
||||
title={isPlaying ? "Pause Evolution" : "Play Evolution"}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? "#58a6ff" : "rgba(88, 166, 255, 0.35)"}`, background: isPlaying ? "rgba(88, 166, 255, 0.2)" : "rgba(88, 166, 255, 0.06)", color: "#58a6ff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(88, 166, 255, 0.4)" : "none" }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
||||
)}
|
||||
</button>
|
||||
<span style={{ fontSize: 10, color: isPlaying ? "#58a6ff" : "#8b949e", fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
|
||||
{displayDate}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: "rgba(88, 166, 255, 0.55)", textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
||||
Temporal Scrubber · {minBound.getFullYear()}-{maxBound.getFullYear()}
|
||||
</div>
|
||||
|
||||
<div className="sem-timeline-wrap" style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export const clickSelectionBehavior: GraphBehavior = {
|
||||
id: "click-selection",
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
onNodeClick: (context, nodeId) => {
|
||||
context.setHoveredNodeId(nodeId);
|
||||
context.onEdgeSelectionChange("");
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
},
|
||||
onEdgeClick: (context, edgeId) => {
|
||||
context.setHoveredNodeId(null);
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
},
|
||||
onStageClick: (context) => {
|
||||
context.setHoveredNodeId(null);
|
||||
context.onEdgeSelectionChange("");
|
||||
context.onNodeSelectionChange("");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export const fitViewBehavior: GraphBehavior = {
|
||||
id: "fit-view",
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
performAction: (context, action) => {
|
||||
if (action.type !== "fitView") {
|
||||
return false;
|
||||
}
|
||||
|
||||
context.fitCurrentView();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export const focusCameraBehavior: GraphBehavior = {
|
||||
id: "focus-camera",
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
performAction: (context, action) => {
|
||||
if (action.type !== "focusNode") {
|
||||
return false;
|
||||
}
|
||||
|
||||
context.focusNodeInView(action.nodeId);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export const hoverActivationBehavior: GraphBehavior = {
|
||||
id: "hover-activation",
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
onNodeEnter: (context, nodeId) => {
|
||||
context.setHoveredNodeId(nodeId);
|
||||
},
|
||||
onNodeLeave: (context, nodeId) => {
|
||||
if (context.getInteractionState().hoveredNodeId === nodeId) {
|
||||
context.setHoveredNodeId(null);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createPathHighlightBehavior(): GraphBehavior {
|
||||
let lastPathSignature = "";
|
||||
|
||||
return {
|
||||
id: "path-highlight",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastPathSignature = "";
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextPathSignature = interactionState.activePath.join("::");
|
||||
if (nextPathSignature === lastPathSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPathSignature = nextPathSignature;
|
||||
context.sigma.refresh();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createSearchFocusBehavior(): GraphBehavior {
|
||||
let lastFocusedNodeId = "";
|
||||
|
||||
return {
|
||||
id: "search-focus",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastFocusedNodeId = "";
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) {
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
return;
|
||||
}
|
||||
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type Graph from "graphology";
|
||||
import type Sigma from "sigma";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../../store/graphStore";
|
||||
import type { GraphCameraState, GraphInteractionState } from "../types";
|
||||
|
||||
export type GraphBehaviorActionRequest =
|
||||
| { type: "fitView" }
|
||||
| { type: "focusNode"; nodeId: string };
|
||||
|
||||
export interface GraphBehaviorContext {
|
||||
sigma: Sigma;
|
||||
graph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
getInteractionState: () => GraphInteractionState;
|
||||
setHoveredNodeId: (nodeId: string | null) => void;
|
||||
onNodeSelectionChange: (nodeId: string) => void;
|
||||
onEdgeSelectionChange: (edgeId: string) => void;
|
||||
focusNodeInView: (nodeId: string) => void;
|
||||
fitCurrentView: () => void;
|
||||
dispatchAction: (action: GraphBehaviorActionRequest) => void;
|
||||
}
|
||||
|
||||
export interface GraphBehavior {
|
||||
id: string;
|
||||
attach: (context: GraphBehaviorContext) => void;
|
||||
detach: (context: GraphBehaviorContext) => void;
|
||||
onNodeEnter?: (context: GraphBehaviorContext, nodeId: string) => void;
|
||||
onNodeLeave?: (context: GraphBehaviorContext, nodeId: string) => void;
|
||||
onNodeClick?: (context: GraphBehaviorContext, nodeId: string) => void;
|
||||
onEdgeClick?: (context: GraphBehaviorContext, edgeId: string) => void;
|
||||
onStageClick?: (context: GraphBehaviorContext) => void;
|
||||
onCameraChange?: (context: GraphBehaviorContext, cameraState: GraphCameraState) => void;
|
||||
onStateChange?: (context: GraphBehaviorContext, interactionState: GraphInteractionState) => void;
|
||||
apply?: (context: GraphBehaviorContext, interactionState: GraphInteractionState) => void;
|
||||
performAction?: (context: GraphBehaviorContext, action: GraphBehaviorActionRequest) => boolean;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
let lastViewMode: "focused" | "full" | null = null;
|
||||
|
||||
return {
|
||||
id: "view-mode-switch",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastViewMode = null;
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
if (interactionState.viewMode === lastViewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastViewMode = interactionState.viewMode;
|
||||
|
||||
if (interactionState.focusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
|
||||
return;
|
||||
}
|
||||
|
||||
context.dispatchAction({ type: "fitView" });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import type Graph from "graphology";
|
||||
import louvain from "graphology-communities-louvain";
|
||||
import { dijkstra } from "graphology-shortest-path";
|
||||
import betweennessCentrality from "graphology-metrics/centrality/betweenness";
|
||||
import {
|
||||
degreeCentrality,
|
||||
inDegreeCentrality,
|
||||
outDegreeCentrality,
|
||||
} from "graphology-metrics/centrality/degree";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, hashString } from "./graphTheme";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDataSnapshot,
|
||||
GraphCentralityNodeSummary,
|
||||
GraphCommunitySummary,
|
||||
GraphInteractionState,
|
||||
GraphSemanticRegionSummary,
|
||||
} from "./types";
|
||||
|
||||
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
|
||||
type GraphCentralityRecord = {
|
||||
degree: number;
|
||||
inDegree: number;
|
||||
outDegree: number;
|
||||
betweenness: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type GraphAnalyticsBase = {
|
||||
communitiesByNode: Map<string, number>;
|
||||
communityCount: number;
|
||||
modularity: number | null;
|
||||
centralityByNode: Map<string, GraphCentralityRecord>;
|
||||
topNodeIds: string[];
|
||||
betweennessReady: boolean;
|
||||
};
|
||||
|
||||
const MAX_BETWEENNESS_NODES = 1400;
|
||||
const MAX_COMMUNITY_SUMMARIES = 6;
|
||||
const MAX_REGION_SUMMARIES = 6;
|
||||
const MAX_CENTRALITY_SUMMARIES = 6;
|
||||
const CENTRALITY_ITERATIONS = 24;
|
||||
const MAX_BACKBONE_ANCHORS = 4;
|
||||
const MAX_BACKBONE_CENTRAL_LINKS = 2;
|
||||
const MAX_BACKBONE_BRIDGES = 4;
|
||||
|
||||
function getNodeLabel(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
return String(attrs.label || attrs.content || nodeId);
|
||||
}
|
||||
|
||||
function getNodeColor(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
return String(attrs.baseColor || attrs.color || "#63E6FF");
|
||||
}
|
||||
|
||||
function getNodeSemanticGroup(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
return String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
}
|
||||
|
||||
function toVisibleNodeSet(graphRef: GraphRef, visibleNodeIds?: Iterable<string>): Set<string> {
|
||||
if (!visibleNodeIds) {
|
||||
return new Set(graphRef.nodes());
|
||||
}
|
||||
|
||||
const visible = new Set<string>();
|
||||
for (const nodeId of visibleNodeIds) {
|
||||
if (graphRef.hasNode(nodeId)) {
|
||||
visible.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return visible.size ? visible : new Set(graphRef.nodes());
|
||||
}
|
||||
|
||||
function buildCentralityRecords(
|
||||
graphRef: GraphRef,
|
||||
includeBetweenness: boolean,
|
||||
): Pick<GraphAnalyticsBase, "centralityByNode" | "topNodeIds" | "betweennessReady"> {
|
||||
const degree = degreeCentrality(graphRef);
|
||||
const inDegree = inDegreeCentrality(graphRef);
|
||||
const outDegree = outDegreeCentrality(graphRef);
|
||||
const betweenness = includeBetweenness
|
||||
? betweennessCentrality(graphRef, { normalized: true, getEdgeWeight: "weight" })
|
||||
: {};
|
||||
|
||||
const centralityByNode = new Map<string, GraphCentralityRecord>();
|
||||
graphRef.forEachNode((nodeId) => {
|
||||
const degreeScore = Number(degree[nodeId] ?? 0);
|
||||
const betweennessScore = Number((betweenness as Record<string, number>)[nodeId] ?? 0);
|
||||
const inDegreeScore = Number(inDegree[nodeId] ?? 0);
|
||||
const outDegreeScore = Number(outDegree[nodeId] ?? 0);
|
||||
centralityByNode.set(nodeId, {
|
||||
degree: degreeScore,
|
||||
inDegree: inDegreeScore,
|
||||
outDegree: outDegreeScore,
|
||||
betweenness: betweennessScore,
|
||||
score: degreeScore * 0.7 + betweennessScore * 0.3,
|
||||
});
|
||||
});
|
||||
|
||||
const topNodeIds = graphRef
|
||||
.nodes()
|
||||
.sort((left, right) => {
|
||||
const leftScore = centralityByNode.get(left)?.score ?? 0;
|
||||
const rightScore = centralityByNode.get(right)?.score ?? 0;
|
||||
if (rightScore !== leftScore) {
|
||||
return rightScore - leftScore;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
return {
|
||||
centralityByNode,
|
||||
topNodeIds,
|
||||
betweennessReady: includeBetweenness,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeGraphAnalyticsBase(
|
||||
graphRef: GraphRef,
|
||||
options?: {
|
||||
computeCommunities?: boolean;
|
||||
computeCentrality?: boolean;
|
||||
},
|
||||
): GraphAnalyticsBase {
|
||||
const shouldComputeCommunities = options?.computeCommunities ?? true;
|
||||
const shouldComputeCentrality = options?.computeCentrality ?? true;
|
||||
const communitiesByNode = new Map<string, number>();
|
||||
let communityCount = 0;
|
||||
let modularity: number | null = null;
|
||||
|
||||
if (shouldComputeCommunities && graphRef.order > 1 && graphRef.size > 0) {
|
||||
try {
|
||||
const result = louvain.detailed(graphRef, { getEdgeWeight: "weight" });
|
||||
modularity = Number(result.modularity ?? 0);
|
||||
communityCount = Number(result.count ?? 0);
|
||||
Object.entries(result.communities).forEach(([nodeId, communityId]) => {
|
||||
communitiesByNode.set(nodeId, Number(communityId));
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[GraphAnalytics] community detection failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldComputeCentrality || graphRef.order === 0) {
|
||||
return {
|
||||
communitiesByNode,
|
||||
communityCount,
|
||||
modularity,
|
||||
centralityByNode: new Map<string, GraphCentralityRecord>(),
|
||||
topNodeIds: [],
|
||||
betweennessReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
const includeBetweenness = graphRef.order <= MAX_BETWEENNESS_NODES;
|
||||
const centrality = buildCentralityRecords(graphRef, includeBetweenness);
|
||||
|
||||
return {
|
||||
communitiesByNode,
|
||||
communityCount,
|
||||
modularity,
|
||||
...centrality,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCommunitySummaries(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
base: GraphAnalyticsBase,
|
||||
): GraphCommunitySummary[] {
|
||||
const grouped = new Map<number, {
|
||||
nodeCount: number;
|
||||
visibleNodeCount: number;
|
||||
semanticCounts: Map<string, number>;
|
||||
anchorNodeId: string | null;
|
||||
prominence: number;
|
||||
color: string;
|
||||
}>();
|
||||
|
||||
graphRef.forEachNode((nodeId) => {
|
||||
const communityId = base.communitiesByNode.get(nodeId);
|
||||
if (communityId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = grouped.get(communityId) ?? {
|
||||
nodeCount: 0,
|
||||
visibleNodeCount: 0,
|
||||
semanticCounts: new Map<string, number>(),
|
||||
anchorNodeId: null,
|
||||
prominence: 0,
|
||||
color: getNodeColor(graphRef, nodeId),
|
||||
};
|
||||
|
||||
entry.nodeCount += 1;
|
||||
if (visibleNodeIds.has(nodeId)) {
|
||||
entry.visibleNodeCount += 1;
|
||||
}
|
||||
|
||||
const semanticGroup = getNodeSemanticGroup(graphRef, nodeId);
|
||||
entry.semanticCounts.set(semanticGroup, (entry.semanticCounts.get(semanticGroup) ?? 0) + 1);
|
||||
|
||||
const nodeScore = base.centralityByNode.get(nodeId)?.score ?? 0;
|
||||
if (!entry.anchorNodeId || nodeScore > (base.centralityByNode.get(entry.anchorNodeId)?.score ?? -1)) {
|
||||
entry.anchorNodeId = nodeId;
|
||||
entry.color = getNodeColor(graphRef, nodeId);
|
||||
}
|
||||
entry.prominence += visibleNodeIds.has(nodeId) ? 1 + nodeScore : nodeScore * 0.15;
|
||||
|
||||
grouped.set(communityId, entry);
|
||||
});
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([communityId, data]) => {
|
||||
const dominantSemanticGroup = [...data.semanticCounts.entries()]
|
||||
.sort((left, right) => right[1] - left[1])[0]?.[0] ?? "entity";
|
||||
return {
|
||||
communityId: String(communityId),
|
||||
nodeCount: data.nodeCount,
|
||||
visibleNodeCount: data.visibleNodeCount,
|
||||
dominantSemanticGroup,
|
||||
color: data.color,
|
||||
anchorNodeId: data.anchorNodeId,
|
||||
anchorLabel: data.anchorNodeId ? getNodeLabel(graphRef, data.anchorNodeId) : "Community anchor",
|
||||
prominence: data.prominence,
|
||||
};
|
||||
})
|
||||
.filter((summary) => summary.visibleNodeCount > 0)
|
||||
.sort((left, right) => right.prominence - left.prominence)
|
||||
.slice(0, MAX_COMMUNITY_SUMMARIES);
|
||||
}
|
||||
|
||||
function buildSemanticRegionSummaries(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
base: GraphAnalyticsBase,
|
||||
): GraphSemanticRegionSummary[] {
|
||||
const grouped = new Map<string, {
|
||||
nodeCount: number;
|
||||
visibleNodeCount: number;
|
||||
communityCounts: Map<string, number>;
|
||||
anchorNodeId: string | null;
|
||||
prominence: number;
|
||||
color: string;
|
||||
}>();
|
||||
|
||||
graphRef.forEachNode((nodeId) => {
|
||||
const semanticGroup = getNodeSemanticGroup(graphRef, nodeId);
|
||||
const entry = grouped.get(semanticGroup) ?? {
|
||||
nodeCount: 0,
|
||||
visibleNodeCount: 0,
|
||||
communityCounts: new Map<string, number>(),
|
||||
anchorNodeId: null,
|
||||
prominence: 0,
|
||||
color: getNodeColor(graphRef, nodeId),
|
||||
};
|
||||
|
||||
entry.nodeCount += 1;
|
||||
if (visibleNodeIds.has(nodeId)) {
|
||||
entry.visibleNodeCount += 1;
|
||||
}
|
||||
|
||||
const communityId = base.communitiesByNode.get(nodeId);
|
||||
if (communityId !== undefined) {
|
||||
const communityKey = String(communityId);
|
||||
entry.communityCounts.set(communityKey, (entry.communityCounts.get(communityKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const nodeScore = base.centralityByNode.get(nodeId)?.score ?? 0;
|
||||
if (!entry.anchorNodeId || nodeScore > (base.centralityByNode.get(entry.anchorNodeId)?.score ?? -1)) {
|
||||
entry.anchorNodeId = nodeId;
|
||||
entry.color = getNodeColor(graphRef, nodeId);
|
||||
}
|
||||
entry.prominence += visibleNodeIds.has(nodeId) ? 1 + nodeScore : nodeScore * 0.1;
|
||||
|
||||
grouped.set(semanticGroup, entry);
|
||||
});
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([semanticGroup, data]) => ({
|
||||
semanticGroup,
|
||||
nodeCount: data.nodeCount,
|
||||
visibleNodeCount: data.visibleNodeCount,
|
||||
color: data.color,
|
||||
anchorNodeId: data.anchorNodeId,
|
||||
anchorLabel: data.anchorNodeId ? getNodeLabel(graphRef, data.anchorNodeId) : semanticGroup,
|
||||
dominantCommunityId: [...data.communityCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null,
|
||||
prominence: data.prominence,
|
||||
}))
|
||||
.filter((summary) => summary.visibleNodeCount > 0)
|
||||
.sort((left, right) => right.prominence - left.prominence)
|
||||
.slice(0, MAX_REGION_SUMMARIES);
|
||||
}
|
||||
|
||||
function buildCentralitySummaries(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
base: GraphAnalyticsBase,
|
||||
): GraphCentralityNodeSummary[] {
|
||||
const candidateIds = base.topNodeIds.filter((nodeId) => visibleNodeIds.has(nodeId));
|
||||
const rankedIds = (candidateIds.length ? candidateIds : base.topNodeIds).slice(0, MAX_CENTRALITY_SUMMARIES);
|
||||
|
||||
return rankedIds.map((nodeId) => {
|
||||
const record = base.centralityByNode.get(nodeId) ?? {
|
||||
degree: 0,
|
||||
betweenness: 0,
|
||||
score: 0,
|
||||
};
|
||||
return {
|
||||
id: nodeId,
|
||||
label: getNodeLabel(graphRef, nodeId),
|
||||
semanticGroup: getNodeSemanticGroup(graphRef, nodeId),
|
||||
color: getNodeColor(graphRef, nodeId),
|
||||
degree: Number(record.degree ?? 0),
|
||||
betweenness: Number(record.betweenness ?? 0),
|
||||
score: Number(record.score ?? 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function collectNodeIncidentEdges(
|
||||
graphRef: GraphRef,
|
||||
nodeId: string,
|
||||
visibleNodeIds: Set<string>,
|
||||
): Array<{ edgeId: string; source: string; target: string; attrs: EdgeAttributes }> {
|
||||
const edges: Array<{ edgeId: string; source: string; target: string; attrs: EdgeAttributes }> = [];
|
||||
|
||||
graphRef.forEachOutEdge(nodeId, (edgeId, attrs, source, target) => {
|
||||
if (!visibleNodeIds.has(target)) {
|
||||
return;
|
||||
}
|
||||
edges.push({ edgeId: String(edgeId), source, target, attrs: attrs as EdgeAttributes });
|
||||
});
|
||||
|
||||
graphRef.forEachInEdge(nodeId, (edgeId, attrs, source, target) => {
|
||||
if (!visibleNodeIds.has(source)) {
|
||||
return;
|
||||
}
|
||||
edges.push({ edgeId: String(edgeId), source, target, attrs: attrs as EdgeAttributes });
|
||||
});
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
function scoreBackboneEdge(
|
||||
attrs: EdgeAttributes,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
base: GraphAnalyticsBase,
|
||||
) {
|
||||
const sourceScore = base.centralityByNode.get(sourceId)?.score ?? 0;
|
||||
const targetScore = base.centralityByNode.get(targetId)?.score ?? 0;
|
||||
const weight = Number(attrs.weight ?? 0);
|
||||
const priority = Number(attrs.visualPriority ?? 0);
|
||||
const parallelBoost = Number(attrs.parallelCount ?? 1) > 1 ? 0.14 : 0;
|
||||
const bidirectionalBoost = attrs.isBidirectional ? 0.18 : 0;
|
||||
|
||||
return weight * 1.4 + (sourceScore + targetScore) * 2.4 + priority * 0.6 + parallelBoost + bidirectionalBoost;
|
||||
}
|
||||
|
||||
function buildOverviewBackboneSnapshot(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
base: GraphAnalyticsBase,
|
||||
semanticRegionSummaries: GraphSemanticRegionSummary[],
|
||||
centralitySummaries: GraphCentralityNodeSummary[],
|
||||
): GraphAnalyticsSnapshot["overviewBackbone"] {
|
||||
if (visibleNodeIds.size === 0) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "No visible nodes are available for overview backbone selection.",
|
||||
edgeIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selectedEdgeIds = new Set<string>();
|
||||
const regionByNode = new Map<string, string>();
|
||||
visibleNodeIds.forEach((nodeId) => {
|
||||
regionByNode.set(nodeId, getNodeSemanticGroup(graphRef, nodeId));
|
||||
});
|
||||
const topRegionIds = new Set(semanticRegionSummaries.slice(0, 3).map((summary) => summary.semanticGroup));
|
||||
const backboneCoreNodeIds = new Set(
|
||||
centralitySummaries
|
||||
.slice(0, MAX_BACKBONE_ANCHORS * 2)
|
||||
.map((summary) => summary.id)
|
||||
.filter((nodeId) => visibleNodeIds.has(nodeId)),
|
||||
);
|
||||
|
||||
const anchorIds = centralitySummaries
|
||||
.slice(0, MAX_BACKBONE_ANCHORS)
|
||||
.map((summary) => summary.id)
|
||||
.filter((nodeId) => visibleNodeIds.has(nodeId));
|
||||
|
||||
const coreLinkCandidates = new Map<string, { edgeId: string; score: number }>();
|
||||
anchorIds.forEach((anchorId) => {
|
||||
collectNodeIncidentEdges(graphRef, anchorId, visibleNodeIds)
|
||||
.filter((entry) => {
|
||||
const otherNodeId = entry.source === anchorId ? entry.target : entry.source;
|
||||
if (!backboneCoreNodeIds.has(otherNodeId)) {
|
||||
return false;
|
||||
}
|
||||
const sourceRegion = regionByNode.get(entry.source);
|
||||
const targetRegion = regionByNode.get(entry.target);
|
||||
return Boolean(sourceRegion && targetRegion && (topRegionIds.has(sourceRegion) || topRegionIds.has(targetRegion)));
|
||||
})
|
||||
.forEach((entry) => {
|
||||
const pairKey = [entry.source, entry.target].sort().join("::");
|
||||
const sourceRegion = regionByNode.get(entry.source);
|
||||
const targetRegion = regionByNode.get(entry.target);
|
||||
const bridgeBoost = sourceRegion && targetRegion && sourceRegion !== targetRegion ? 0.28 : 0;
|
||||
const score = scoreBackboneEdge(entry.attrs, entry.source, entry.target, base) + bridgeBoost;
|
||||
const current = coreLinkCandidates.get(pairKey);
|
||||
if (!current || score > current.score || (score === current.score && entry.edgeId.localeCompare(current.edgeId) < 0)) {
|
||||
coreLinkCandidates.set(pairKey, { edgeId: entry.edgeId, score });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const bridgeByPair = new Map<string, { edgeId: string; score: number }>();
|
||||
graphRef.forEachEdge((edgeId, attrs, source, target) => {
|
||||
if (!visibleNodeIds.has(source) || !visibleNodeIds.has(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceRegion = regionByNode.get(source);
|
||||
const targetRegion = regionByNode.get(target);
|
||||
if (!sourceRegion || !targetRegion || sourceRegion === targetRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!topRegionIds.has(sourceRegion) && !topRegionIds.has(targetRegion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pairKey = [sourceRegion, targetRegion].sort().join("::");
|
||||
const score = scoreBackboneEdge(attrs as EdgeAttributes, source, target, base) + 0.36;
|
||||
const current = bridgeByPair.get(pairKey);
|
||||
if (!current || score > current.score || (score === current.score && String(edgeId).localeCompare(current.edgeId) < 0)) {
|
||||
bridgeByPair.set(pairKey, { edgeId: String(edgeId), score });
|
||||
}
|
||||
});
|
||||
|
||||
[...bridgeByPair.values()]
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
})
|
||||
.slice(0, MAX_BACKBONE_BRIDGES)
|
||||
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
|
||||
|
||||
[...coreLinkCandidates.values()]
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
})
|
||||
.slice(0, MAX_BACKBONE_CENTRAL_LINKS)
|
||||
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
|
||||
|
||||
const edgeIds = [...selectedEdgeIds]
|
||||
.filter((edgeId) => graphRef.hasEdge(edgeId))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
return {
|
||||
ready: edgeIds.length > 0,
|
||||
reason: edgeIds.length > 0
|
||||
? "Ready"
|
||||
: "No overview backbone edges met the current visibility thresholds.",
|
||||
edgeIds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirectedPathSnapshot(
|
||||
graphRef: GraphRef,
|
||||
interactionState: GraphInteractionState,
|
||||
): GraphAnalyticsSnapshot["directedPath"] {
|
||||
if (interactionState.activePath.length < 2) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "Trace a path to compare it against local strict directed shortest pathfinding.",
|
||||
sourceId: interactionState.selectedNodeId || null,
|
||||
targetId: null,
|
||||
path: [],
|
||||
length: null,
|
||||
verifiedAgainstActivePath: false,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceId = interactionState.activePath[0] ?? null;
|
||||
const targetId = interactionState.activePath[interactionState.activePath.length - 1] ?? null;
|
||||
if (!sourceId || !targetId || !graphRef.hasNode(sourceId) || !graphRef.hasNode(targetId)) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "Active path endpoints are not available in the current graph view.",
|
||||
sourceId,
|
||||
targetId,
|
||||
path: [],
|
||||
length: null,
|
||||
verifiedAgainstActivePath: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const path = dijkstra.bidirectional(graphRef, sourceId, targetId, "weight") ?? [];
|
||||
return {
|
||||
ready: path.length > 1,
|
||||
reason: path.length > 1
|
||||
? "Ready"
|
||||
: "No strict directed shortest path found in the current graph view.",
|
||||
sourceId,
|
||||
targetId,
|
||||
path,
|
||||
length: path.length > 1 ? path.length - 1 : null,
|
||||
verifiedAgainstActivePath: path.join("::") === interactionState.activePath.join("::"),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[GraphAnalytics] directed pathfinding failed", error);
|
||||
return {
|
||||
ready: false,
|
||||
reason: "Directed pathfinding failed for the current graph snapshot.",
|
||||
sourceId,
|
||||
targetId,
|
||||
path: [],
|
||||
length: null,
|
||||
verifiedAgainstActivePath: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGraphAnalyticsSnapshot(params: {
|
||||
graphRef: GraphRef;
|
||||
interactionState: GraphInteractionState;
|
||||
base: GraphAnalyticsBase;
|
||||
visibleNodeIds?: Iterable<string>;
|
||||
}): GraphAnalyticsSnapshot {
|
||||
const { graphRef, interactionState, base } = params;
|
||||
const visibleNodeIds = toVisibleNodeSet(graphRef, params.visibleNodeIds);
|
||||
const directedPath = buildDirectedPathSnapshot(graphRef, interactionState);
|
||||
const communitySummaries = base.communitiesByNode.size
|
||||
? buildCommunitySummaries(graphRef, visibleNodeIds, base)
|
||||
: [];
|
||||
const semanticRegionSummaries = buildSemanticRegionSummaries(graphRef, visibleNodeIds, base);
|
||||
const centralitySummaries = buildCentralitySummaries(graphRef, visibleNodeIds, base);
|
||||
const overviewBackbone = buildOverviewBackboneSnapshot(
|
||||
graphRef,
|
||||
visibleNodeIds,
|
||||
base,
|
||||
semanticRegionSummaries,
|
||||
centralitySummaries,
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: Date.now(),
|
||||
directedPath,
|
||||
communities: {
|
||||
ready: communitySummaries.length > 0,
|
||||
reason: communitySummaries.length > 0
|
||||
? "Ready"
|
||||
: base.communitiesByNode.size > 0
|
||||
? "No visible communities in the current graph context."
|
||||
: "Community detection has not produced summaries yet.",
|
||||
count: base.communityCount,
|
||||
modularity: base.modularity,
|
||||
summaries: communitySummaries,
|
||||
},
|
||||
centrality: {
|
||||
ready: centralitySummaries.length > 0,
|
||||
reason: centralitySummaries.length > 0
|
||||
? base.betweennessReady
|
||||
? "Ready"
|
||||
: "Ready (degree-biased while betweenness is bounded for large graphs)."
|
||||
: "Centrality ranking is waiting for graph data.",
|
||||
topNodes: centralitySummaries,
|
||||
},
|
||||
semanticRegions: {
|
||||
ready: semanticRegionSummaries.length > 0,
|
||||
reason: semanticRegionSummaries.length > 0
|
||||
? "Ready"
|
||||
: "No semantic regions are visible in the current graph context.",
|
||||
summaries: semanticRegionSummaries,
|
||||
},
|
||||
overviewBackbone,
|
||||
};
|
||||
}
|
||||
|
||||
export function colorForNodeKey(key: string): string {
|
||||
const palette = GRAPH_THEME.palette.semantic;
|
||||
return palette[hashString(key) % palette.length];
|
||||
}
|
||||
|
||||
export function chooseColorAccessor(
|
||||
nodes: Array<{ id: string; attributes: Pick<NodeAttributes, "nodeType" | "content"> }>,
|
||||
) {
|
||||
return (id: string, attributes: Pick<NodeAttributes, "nodeType" | "content">) => {
|
||||
const semanticKey = String(attributes.nodeType || attributes.content || id);
|
||||
const node = nodes.find((entry) => entry.id === id);
|
||||
const fallbackKey = String(node?.attributes.nodeType || node?.attributes.content || semanticKey);
|
||||
return `${fallbackKey}:${id}`;
|
||||
};
|
||||
}
|
||||
|
||||
export function deterministicPosition(nodeId: string, index: number, totalNodes: number) {
|
||||
const angle = (hashString(nodeId) % 360) * (Math.PI / 180);
|
||||
const ring = Math.floor(index / Math.max(12, Math.ceil(Math.sqrt(Math.max(totalNodes, 1)))));
|
||||
const radius = 120 + ring * 90 + (hashString(`${nodeId}:radius`) % 46);
|
||||
return {
|
||||
x: Math.cos(angle) * radius,
|
||||
y: Math.sin(angle) * radius,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeDegreeMap(
|
||||
nodes: GraphDataSnapshot["nodes"],
|
||||
edges: GraphDataSnapshot["edges"],
|
||||
) {
|
||||
const degreeByNode = new Map<string, number>();
|
||||
nodes.forEach((node) => degreeByNode.set(node.id, 0));
|
||||
edges.forEach((edge) => {
|
||||
degreeByNode.set(edge.source, (degreeByNode.get(edge.source) ?? 0) + 1);
|
||||
degreeByNode.set(edge.target, (degreeByNode.get(edge.target) ?? 0) + 1);
|
||||
});
|
||||
return degreeByNode;
|
||||
}
|
||||
|
||||
export function computePageRank(
|
||||
nodes: GraphDataSnapshot["nodes"],
|
||||
edges: GraphDataSnapshot["edges"],
|
||||
) {
|
||||
const pageRank = new Map<string, number>();
|
||||
const outbound = new Map<string, string[]>();
|
||||
const inbound = new Map<string, string[]>();
|
||||
const nodeCount = Math.max(nodes.length, 1);
|
||||
|
||||
nodes.forEach((node) => {
|
||||
pageRank.set(node.id, 1 / nodeCount);
|
||||
outbound.set(node.id, []);
|
||||
inbound.set(node.id, []);
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
outbound.set(edge.source, [...(outbound.get(edge.source) ?? []), edge.target]);
|
||||
inbound.set(edge.target, [...(inbound.get(edge.target) ?? []), edge.source]);
|
||||
});
|
||||
|
||||
for (let iteration = 0; iteration < CENTRALITY_ITERATIONS; iteration += 1) {
|
||||
const next = new Map<string, number>();
|
||||
nodes.forEach((node) => {
|
||||
const incoming = inbound.get(node.id) ?? [];
|
||||
let sum = 0;
|
||||
incoming.forEach((sourceId) => {
|
||||
const outDegree = (outbound.get(sourceId) ?? []).length || nodeCount;
|
||||
sum += (pageRank.get(sourceId) ?? 0) / outDegree;
|
||||
});
|
||||
next.set(node.id, 0.15 / nodeCount + 0.85 * sum);
|
||||
});
|
||||
next.forEach((value, nodeId) => pageRank.set(nodeId, value));
|
||||
}
|
||||
|
||||
return pageRank;
|
||||
}
|
||||
|
||||
export function computeNodeSize(
|
||||
nodeId: string,
|
||||
degreeByNode: Map<string, number>,
|
||||
pageRankByNode: Map<string, number>,
|
||||
) {
|
||||
const degree = degreeByNode.get(nodeId) ?? 0;
|
||||
const pageRank = pageRankByNode.get(nodeId) ?? 0;
|
||||
return 5.5 + Math.min(16, degree * 0.18 + pageRank * 160);
|
||||
}
|
||||
|
||||
export function computeEdgeSize(weight: number) {
|
||||
return Math.max(0.8, Math.min(3.2, 0.9 + Math.log2(Math.max(weight, 1) + 1) * 0.36));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export const GRAPH_THEME = {
|
||||
background: {
|
||||
canvas: "#050816",
|
||||
shell: "#0A1021",
|
||||
panel: "#10182C",
|
||||
grid: "rgba(93, 124, 168, 0.08)",
|
||||
vignette: "rgba(2, 4, 10, 0.88)",
|
||||
},
|
||||
nodes: {
|
||||
palette: [
|
||||
"#3CE7FF",
|
||||
"#23D7C8",
|
||||
"#5DA9FF",
|
||||
"#9B6BFF",
|
||||
"#FF4FD8",
|
||||
"#FF9A3C",
|
||||
"#B9FF3B",
|
||||
],
|
||||
selected: "#FFC857",
|
||||
selectedGlow: "rgba(255, 200, 87, 0.36)",
|
||||
hoverGlow: "rgba(60, 231, 255, 0.32)",
|
||||
border: "#07111C",
|
||||
subduedAlpha: 0.1,
|
||||
},
|
||||
edges: {
|
||||
baseColor: "rgba(126, 162, 214, 0.08)",
|
||||
subduedColor: "rgba(126, 162, 214, 0.03)",
|
||||
hoverColor: "rgba(94, 198, 255, 0.9)",
|
||||
pathColor: "rgba(255, 192, 92, 0.95)",
|
||||
focusColor: "rgba(175, 191, 255, 0.26)",
|
||||
},
|
||||
motion: {
|
||||
hoverMs: 160,
|
||||
cameraMs: 480,
|
||||
},
|
||||
thresholds: {
|
||||
interactiveLayoutMaxNodes: 8000,
|
||||
stagedLayoutMaxNodes: 25000,
|
||||
focusNeighborCap: 18,
|
||||
focusPrimaryLabels: 8,
|
||||
particleEdgeCap: 32,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const FORCE_ATLAS_SETTINGS = {
|
||||
getEdgeWeight: "weight",
|
||||
settings: {
|
||||
barnesHutOptimize: true,
|
||||
barnesHutTheta: 0.6,
|
||||
linLogMode: true,
|
||||
outboundAttractionDistribution: true,
|
||||
strongGravityMode: false,
|
||||
gravity: 0.14,
|
||||
scalingRatio: 4.8,
|
||||
slowDown: 6,
|
||||
edgeWeightInfluence: 1,
|
||||
adjustSizes: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function clamp(min: number, value: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function withAlpha(color: string | undefined, alpha: number): string {
|
||||
if (!color) {
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
|
||||
if (normalized.length === 6) {
|
||||
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (color.startsWith("rgba(")) {
|
||||
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
}
|
||||
|
||||
if (color.startsWith("rgb(")) {
|
||||
return color.replace("rgb(", "rgba(").replace(")", `, ${alpha})`);
|
||||
}
|
||||
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { GraphLoadPhase, GraphLoadProgress, GraphLoadProgressKind, GraphLayoutSource, GraphLayoutState } from "./types";
|
||||
|
||||
export const GRAPH_LOAD_STAGE_SEQUENCE: Exclude<GraphLoadPhase, "ready">[] = [
|
||||
"bootstrapping",
|
||||
"fetching_nodes",
|
||||
"fetching_edges",
|
||||
"computing_styling",
|
||||
"hydrating_scene",
|
||||
"stabilizing_layout",
|
||||
];
|
||||
|
||||
export function getGraphLoadTitle(phase: GraphLoadPhase): string {
|
||||
switch (phase) {
|
||||
case "bootstrapping":
|
||||
return "Preparing graph session";
|
||||
case "fetching_nodes":
|
||||
return "Loading nodes";
|
||||
case "fetching_edges":
|
||||
return "Loading relationships";
|
||||
case "computing_styling":
|
||||
return "Computing node styling";
|
||||
case "hydrating_scene":
|
||||
return "Hydrating graph scene";
|
||||
case "stabilizing_layout":
|
||||
return "Stabilizing layout";
|
||||
case "ready":
|
||||
default:
|
||||
return "Graph ready";
|
||||
}
|
||||
}
|
||||
|
||||
export function getGraphLoadStageLabel(phase: Exclude<GraphLoadPhase, "ready">): string {
|
||||
switch (phase) {
|
||||
case "bootstrapping":
|
||||
return "Prepare";
|
||||
case "fetching_nodes":
|
||||
return "Nodes";
|
||||
case "fetching_edges":
|
||||
return "Relations";
|
||||
case "computing_styling":
|
||||
return "Styling";
|
||||
case "hydrating_scene":
|
||||
return "Scene";
|
||||
case "stabilizing_layout":
|
||||
return "Layout";
|
||||
default:
|
||||
return "Stage";
|
||||
}
|
||||
}
|
||||
|
||||
export function createGraphLoadProgress(input: {
|
||||
phase: GraphLoadPhase;
|
||||
message: string;
|
||||
progressKind: GraphLoadProgressKind;
|
||||
loaded?: number | null;
|
||||
total?: number | null;
|
||||
nodesLoaded?: number;
|
||||
nodesTotal?: number | null;
|
||||
edgesLoaded?: number;
|
||||
edgesTotal?: number | null;
|
||||
showGraphBehind?: boolean;
|
||||
layoutSource?: GraphLayoutSource;
|
||||
layoutState?: GraphLayoutState;
|
||||
}): GraphLoadProgress {
|
||||
const phaseIndex = GRAPH_LOAD_STAGE_SEQUENCE.indexOf(input.phase as Exclude<GraphLoadPhase, "ready">);
|
||||
|
||||
return {
|
||||
phase: input.phase,
|
||||
title: getGraphLoadTitle(input.phase),
|
||||
message: input.message,
|
||||
progressKind: input.progressKind,
|
||||
loaded: input.loaded ?? null,
|
||||
total: input.total ?? null,
|
||||
nodesLoaded: input.nodesLoaded ?? 0,
|
||||
nodesTotal: input.nodesTotal ?? null,
|
||||
edgesLoaded: input.edgesLoaded ?? 0,
|
||||
edgesTotal: input.edgesTotal ?? null,
|
||||
showGraphBehind: input.showGraphBehind ?? false,
|
||||
stageIndex: phaseIndex >= 0 ? phaseIndex + 1 : GRAPH_LOAD_STAGE_SEQUENCE.length,
|
||||
stageCount: GRAPH_LOAD_STAGE_SEQUENCE.length,
|
||||
layoutSource: input.layoutSource,
|
||||
layoutState: input.layoutState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import type Graph from "graphology";
|
||||
import Sigma from "sigma";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import { blendHex, GRAPH_THEME, withAlpha, zoomTierAtLeast } from "./graphTheme";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
GraphTemporalState,
|
||||
} from "./types";
|
||||
|
||||
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
|
||||
export type ViewportPoint = { x: number; y: number };
|
||||
|
||||
export type PathSegmentOverlay = {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
source: ViewportPoint;
|
||||
target: ViewportPoint;
|
||||
color: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type VisibleNodeSample = {
|
||||
nodeId: string;
|
||||
point: ViewportPoint;
|
||||
size: number;
|
||||
attrs: NodeAttributes;
|
||||
};
|
||||
|
||||
function isPointNearViewport(point: ViewportPoint, width: number, height: number, padding = 96) {
|
||||
return point.x >= -padding
|
||||
&& point.y >= -padding
|
||||
&& point.x <= width + padding
|
||||
&& point.y <= height + padding;
|
||||
}
|
||||
|
||||
function drawGlowHalo(
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
color: string,
|
||||
) {
|
||||
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
context.arc(x, y, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function createScratchCanvas(width: number, height: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function parseCssColor(color: string) {
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
if (normalized.length === 6) {
|
||||
return {
|
||||
r: Number.parseInt(normalized.slice(0, 2), 16),
|
||||
g: Number.parseInt(normalized.slice(2, 4), 16),
|
||||
b: Number.parseInt(normalized.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||
if (match) {
|
||||
return {
|
||||
r: Number.parseInt(match[1], 10),
|
||||
g: Number.parseInt(match[2], 10),
|
||||
b: Number.parseInt(match[3], 10),
|
||||
};
|
||||
}
|
||||
|
||||
return { r: 130, g: 145, b: 165 };
|
||||
}
|
||||
|
||||
function createThresholdMask(
|
||||
alphaValues: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
threshold: number,
|
||||
) {
|
||||
const mask = new Uint8Array(width * height);
|
||||
let count = 0;
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let sumWeight = 0;
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = y * width + x;
|
||||
const alpha = alphaValues[index];
|
||||
if (alpha < threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mask[index] = 1;
|
||||
count += 1;
|
||||
sumX += x * alpha;
|
||||
sumY += y * alpha;
|
||||
sumWeight += alpha;
|
||||
}
|
||||
}
|
||||
|
||||
const centroid = sumWeight > 0
|
||||
? { x: sumX / sumWeight, y: sumY / sumWeight }
|
||||
: { x: width / 2, y: height / 2 };
|
||||
|
||||
return { mask, count, centroid };
|
||||
}
|
||||
|
||||
function blurAlphaValues(
|
||||
alphaValues: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
passes: number,
|
||||
) {
|
||||
if (passes <= 0) {
|
||||
return alphaValues;
|
||||
}
|
||||
|
||||
let source = alphaValues;
|
||||
for (let pass = 0; pass < passes; pass += 1) {
|
||||
const next = new Uint8ClampedArray(width * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let sum = 0;
|
||||
let samples = 0;
|
||||
for (let oy = -1; oy <= 1; oy += 1) {
|
||||
const sampleY = y + oy;
|
||||
if (sampleY < 0 || sampleY >= height) {
|
||||
continue;
|
||||
}
|
||||
for (let ox = -1; ox <= 1; ox += 1) {
|
||||
const sampleX = x + ox;
|
||||
if (sampleX < 0 || sampleX >= width) {
|
||||
continue;
|
||||
}
|
||||
sum += source[sampleY * width + sampleX];
|
||||
samples += 1;
|
||||
}
|
||||
}
|
||||
next[y * width + x] = Math.round(sum / Math.max(samples, 1));
|
||||
}
|
||||
}
|
||||
source = next;
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
function isolateDominantMask(
|
||||
mask: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
alphaValues: Uint8ClampedArray,
|
||||
) {
|
||||
const visited = new Uint8Array(mask.length);
|
||||
const queue = new Int32Array(mask.length);
|
||||
let bestPixels: number[] = [];
|
||||
let totalCount = 0;
|
||||
|
||||
for (let index = 0; index < mask.length; index += 1) {
|
||||
if (!mask[index] || visited[index]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
const component: number[] = [];
|
||||
visited[index] = 1;
|
||||
queue[tail++] = index;
|
||||
|
||||
while (head < tail) {
|
||||
const current = queue[head++];
|
||||
component.push(current);
|
||||
|
||||
const x = current % width;
|
||||
const y = Math.floor(current / width);
|
||||
const neighbors = [
|
||||
current - 1,
|
||||
current + 1,
|
||||
current - width,
|
||||
current + width,
|
||||
];
|
||||
|
||||
for (let n = 0; n < neighbors.length; n += 1) {
|
||||
const neighbor = neighbors[n];
|
||||
if (neighbor < 0 || neighbor >= mask.length || visited[neighbor] || !mask[neighbor]) {
|
||||
continue;
|
||||
}
|
||||
if ((n === 0 && x === 0) || (n === 1 && x === width - 1) || (n === 2 && y === 0) || (n === 3 && y === height - 1)) {
|
||||
continue;
|
||||
}
|
||||
visited[neighbor] = 1;
|
||||
queue[tail++] = neighbor;
|
||||
}
|
||||
}
|
||||
|
||||
totalCount += component.length;
|
||||
if (component.length > bestPixels.length) {
|
||||
bestPixels = component;
|
||||
}
|
||||
}
|
||||
|
||||
const dominantMask = new Uint8Array(mask.length);
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let sumWeight = 0;
|
||||
bestPixels.forEach((index) => {
|
||||
dominantMask[index] = 1;
|
||||
const alpha = alphaValues[index];
|
||||
const x = index % width;
|
||||
const y = Math.floor(index / width);
|
||||
sumX += x * alpha;
|
||||
sumY += y * alpha;
|
||||
sumWeight += alpha;
|
||||
});
|
||||
|
||||
return {
|
||||
mask: dominantMask,
|
||||
count: bestPixels.length,
|
||||
centroid: sumWeight > 0
|
||||
? { x: sumX / sumWeight, y: sumY / sumWeight }
|
||||
: { x: width / 2, y: height / 2 },
|
||||
occupancyRatio: bestPixels.length / Math.max(width * height, 1),
|
||||
dominantMassRatio: bestPixels.length / Math.max(totalCount, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function renderDensityField(
|
||||
samples: VisibleNodeSample[],
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const scale = GRAPH_THEME.effects.semanticRegions.fogResolutionScale;
|
||||
const gridWidth = Math.max(72, Math.round(width * scale));
|
||||
const gridHeight = Math.max(72, Math.round(height * scale));
|
||||
const canvas = createScratchCanvas(gridWidth, gridHeight);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, gridWidth, gridHeight);
|
||||
context.globalCompositeOperation = "source-over";
|
||||
|
||||
samples.forEach((sample) => {
|
||||
const x = sample.point.x * scale;
|
||||
const y = sample.point.y * scale;
|
||||
const radius = Math.max(
|
||||
2.5,
|
||||
(GRAPH_THEME.effects.semanticRegions.splatRadius + sample.size * 0.9) * scale,
|
||||
);
|
||||
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0.22)");
|
||||
gradient.addColorStop(0.58, "rgba(255,255,255,0.08)");
|
||||
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
context.arc(x, y, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
|
||||
const imageData = context.getImageData(0, 0, gridWidth, gridHeight);
|
||||
const alphaValues = new Uint8ClampedArray(gridWidth * gridHeight);
|
||||
let maxAlpha = 0;
|
||||
for (let index = 0; index < alphaValues.length; index += 1) {
|
||||
const alpha = imageData.data[index * 4 + 3];
|
||||
alphaValues[index] = alpha;
|
||||
if (alpha > maxAlpha) {
|
||||
maxAlpha = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxAlpha <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blurredAlphaValues = blurAlphaValues(
|
||||
alphaValues,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
GRAPH_THEME.effects.semanticRegions.blurPasses,
|
||||
);
|
||||
const blurredMaxAlpha = blurredAlphaValues.reduce((value, alpha) => Math.max(value, alpha), 0);
|
||||
if (blurredMaxAlpha <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { canvas, gridWidth, gridHeight, alphaValues: blurredAlphaValues, maxAlpha: blurredMaxAlpha, scale };
|
||||
}
|
||||
|
||||
function drawDensityFog(
|
||||
context: CanvasRenderingContext2D,
|
||||
densityField: ReturnType<typeof renderDensityField>,
|
||||
color: string,
|
||||
alpha: number,
|
||||
) {
|
||||
if (!densityField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { gridWidth, gridHeight, alphaValues } = densityField;
|
||||
const fogCanvas = createScratchCanvas(gridWidth, gridHeight);
|
||||
const fogContext = fogCanvas.getContext("2d");
|
||||
if (!fogContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = fogContext.createImageData(gridWidth, gridHeight);
|
||||
const rgb = parseCssColor(color);
|
||||
for (let index = 0; index < alphaValues.length; index += 1) {
|
||||
const sourceAlpha = alphaValues[index] / 255;
|
||||
if (sourceAlpha <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pixelIndex = index * 4;
|
||||
imageData.data[pixelIndex] = rgb.r;
|
||||
imageData.data[pixelIndex + 1] = rgb.g;
|
||||
imageData.data[pixelIndex + 2] = rgb.b;
|
||||
imageData.data[pixelIndex + 3] = Math.round(255 * Math.pow(sourceAlpha, 1.7) * alpha);
|
||||
}
|
||||
|
||||
fogContext.putImageData(imageData, 0, 0);
|
||||
context.save();
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.globalCompositeOperation = "lighter";
|
||||
context.drawImage(fogCanvas, 0, 0, context.canvas.width, context.canvas.height);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawMaskContour(
|
||||
context: CanvasRenderingContext2D,
|
||||
mask: Uint8Array,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
color: string,
|
||||
alpha: number,
|
||||
) {
|
||||
const contourCanvas = createScratchCanvas(gridWidth, gridHeight);
|
||||
const contourContext = contourCanvas.getContext("2d");
|
||||
if (!contourContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = contourContext.createImageData(gridWidth, gridHeight);
|
||||
const rgb = parseCssColor(color);
|
||||
|
||||
for (let y = 1; y < gridHeight - 1; y += 1) {
|
||||
for (let x = 1; x < gridWidth - 1; x += 1) {
|
||||
const index = y * gridWidth + x;
|
||||
if (!mask[index]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isEdge = !mask[index - 1]
|
||||
|| !mask[index + 1]
|
||||
|| !mask[index - gridWidth]
|
||||
|| !mask[index + gridWidth];
|
||||
if (!isEdge) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pixelIndex = index * 4;
|
||||
imageData.data[pixelIndex] = rgb.r;
|
||||
imageData.data[pixelIndex + 1] = rgb.g;
|
||||
imageData.data[pixelIndex + 2] = rgb.b;
|
||||
imageData.data[pixelIndex + 3] = Math.round(255 * alpha);
|
||||
}
|
||||
}
|
||||
|
||||
contourContext.putImageData(imageData, 0, 0);
|
||||
context.save();
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.drawImage(contourCanvas, 0, 0, context.canvas.width, context.canvas.height);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function buildRegionRenderColors(color: string) {
|
||||
return {
|
||||
fogColor: blendHex("#0E1927", color, 0.46),
|
||||
innerContourColor: blendHex("#1C2C3F", color, 0.78),
|
||||
outerContourColor: blendHex("#132131", color, 0.6),
|
||||
labelBorderColor: blendHex("#2B3F57", color, 0.78),
|
||||
};
|
||||
}
|
||||
|
||||
export function collectVisibleNodeSamples(
|
||||
sigma: Sigma,
|
||||
graphRef: GraphRef,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
): VisibleNodeSample[] {
|
||||
const samples: VisibleNodeSample[] = [];
|
||||
|
||||
graphRef.forEachNode((nodeId, attrs) => {
|
||||
const displayData = sigma.getNodeDisplayData(nodeId);
|
||||
if (!displayData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = sigma.graphToViewport({ x: displayData.x, y: displayData.y });
|
||||
if (!isPointNearViewport(point, viewportWidth, viewportHeight, 84)) {
|
||||
return;
|
||||
}
|
||||
|
||||
samples.push({
|
||||
nodeId,
|
||||
point,
|
||||
size: displayData.size,
|
||||
attrs: attrs as NodeAttributes,
|
||||
});
|
||||
});
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
function drawRegionLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
color: string,
|
||||
) {
|
||||
const label = text.length > 22 ? `${text.slice(0, 21)}…` : text;
|
||||
context.save();
|
||||
context.font = `600 11px "IBM Plex Sans", Inter, system-ui, sans-serif`;
|
||||
context.textBaseline = "middle";
|
||||
const width = context.measureText(label).width + 14;
|
||||
const height = 22;
|
||||
const left = x - width / 2;
|
||||
const top = y - height / 2;
|
||||
|
||||
context.fillStyle = "rgba(8, 14, 24, 0.84)";
|
||||
context.strokeStyle = withAlpha(color, 0.24);
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.roundRect(left, top, width, height, 999);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
|
||||
context.fillStyle = "rgba(235, 244, 255, 0.88)";
|
||||
context.fillText(label, left + 7, y);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
export function drawSemanticRegionsLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
analytics: GraphAnalyticsSnapshot | null,
|
||||
visibleNodes: VisibleNodeSample[],
|
||||
interactionState: GraphInteractionState,
|
||||
effectsState: GraphEffectsState,
|
||||
) {
|
||||
if (!effectsState.semanticRegionsEnabled || !analytics?.semanticRegions.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.semanticRegions.minZoomTier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleByGroup = new Map<string, VisibleNodeSample[]>();
|
||||
visibleNodes.forEach((sample) => {
|
||||
const group = String(sample.attrs.semanticGroup || sample.attrs.nodeType || "entity");
|
||||
const entry = visibleByGroup.get(group);
|
||||
if (entry) {
|
||||
entry.push(sample);
|
||||
} else {
|
||||
visibleByGroup.set(group, [sample]);
|
||||
}
|
||||
});
|
||||
|
||||
const summaries = analytics.semanticRegions.summaries
|
||||
.slice(0, GRAPH_THEME.effects.semanticRegions.maxRegions);
|
||||
const maxProminence = summaries.reduce((value, summary) => Math.max(value, summary.prominence), 1);
|
||||
const semanticConfig = GRAPH_THEME.effects.semanticRegions;
|
||||
|
||||
summaries.forEach((summary) => {
|
||||
const samples = visibleByGroup.get(summary.semanticGroup);
|
||||
if (!samples || samples.length < semanticConfig.minVisibleSamples) {
|
||||
return;
|
||||
}
|
||||
|
||||
const densityField = renderDensityField(samples, context.canvas.width, context.canvas.height);
|
||||
if (!densityField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prominenceRatio = 0.52 + (summary.prominence / maxProminence) * 0.48;
|
||||
const densityThreshold = Math.max(18, densityField.maxAlpha * semanticConfig.densityThreshold);
|
||||
const contourThreshold = Math.max(28, densityField.maxAlpha * semanticConfig.contourThreshold);
|
||||
const outerContourThreshold = Math.max(18, contourThreshold * 0.72);
|
||||
|
||||
const densityMask = createThresholdMask(
|
||||
densityField.alphaValues,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
densityThreshold,
|
||||
);
|
||||
const dominantDensityMask = isolateDominantMask(
|
||||
densityMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
densityField.alphaValues,
|
||||
);
|
||||
if (
|
||||
dominantDensityMask.count < semanticConfig.minMaskPixels
|
||||
|| dominantDensityMask.occupancyRatio < semanticConfig.minOccupancyRatio
|
||||
|| dominantDensityMask.dominantMassRatio < semanticConfig.dominantMassRatio
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contourMask = createThresholdMask(
|
||||
densityField.alphaValues,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
contourThreshold,
|
||||
);
|
||||
const outerContourMask = createThresholdMask(
|
||||
densityField.alphaValues,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
outerContourThreshold,
|
||||
);
|
||||
const dominantContourMask = isolateDominantMask(
|
||||
contourMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
densityField.alphaValues,
|
||||
);
|
||||
const dominantOuterContourMask = isolateDominantMask(
|
||||
outerContourMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
densityField.alphaValues,
|
||||
);
|
||||
|
||||
const colors = buildRegionRenderColors(summary.color);
|
||||
drawDensityFog(
|
||||
context,
|
||||
densityField,
|
||||
colors.fogColor,
|
||||
semanticConfig.fogAlpha * prominenceRatio,
|
||||
);
|
||||
if (
|
||||
dominantOuterContourMask.count >= semanticConfig.outerContourMinMaskPixels
|
||||
&& dominantOuterContourMask.dominantMassRatio >= semanticConfig.dominantMassRatio
|
||||
) {
|
||||
drawMaskContour(
|
||||
context,
|
||||
dominantOuterContourMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
colors.outerContourColor,
|
||||
semanticConfig.outerContourAlpha * prominenceRatio,
|
||||
);
|
||||
}
|
||||
drawMaskContour(
|
||||
context,
|
||||
dominantContourMask.mask,
|
||||
densityField.gridWidth,
|
||||
densityField.gridHeight,
|
||||
colors.innerContourColor,
|
||||
semanticConfig.innerContourAlpha * prominenceRatio,
|
||||
);
|
||||
|
||||
if (summary.visibleNodeCount >= 18) {
|
||||
const labelX = dominantDensityMask.centroid.x / densityField.scale;
|
||||
const labelY = dominantDensityMask.centroid.y / densityField.scale - 18;
|
||||
drawRegionLabel(
|
||||
context,
|
||||
labelX,
|
||||
labelY,
|
||||
summary.semanticGroup,
|
||||
colors.labelBorderColor,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function drawContourLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
analytics: GraphAnalyticsSnapshot | null,
|
||||
visibleNodes: VisibleNodeSample[],
|
||||
interactionState: GraphInteractionState,
|
||||
effectsState: GraphEffectsState,
|
||||
) {
|
||||
if (!effectsState.contoursEnabled || !analytics?.centrality.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.contours.minZoomTier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleById = new Map(visibleNodes.map((sample) => [sample.nodeId, sample] as const));
|
||||
|
||||
analytics.centrality.topNodes.slice(0, GRAPH_THEME.effects.contours.maxContours).forEach((node) => {
|
||||
const sample = visibleById.get(node.id);
|
||||
if (!sample) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawGlowHalo(
|
||||
context,
|
||||
sample.point.x,
|
||||
sample.point.y,
|
||||
GRAPH_THEME.effects.contours.baseRadius + sample.size * 3.2,
|
||||
withAlpha(node.color, GRAPH_THEME.effects.contours.glowAlpha),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isTemporalNodeActive(attrs: NodeAttributes, currentTime: Date | null) {
|
||||
if (!currentTime || (!attrs.valid_from && !attrs.valid_until)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const time = currentTime.getTime();
|
||||
const from = attrs.valid_from ? new Date(attrs.valid_from).getTime() : Number.NEGATIVE_INFINITY;
|
||||
const until = attrs.valid_until ? new Date(attrs.valid_until).getTime() : Number.POSITIVE_INFINITY;
|
||||
return time >= from && time <= until;
|
||||
}
|
||||
|
||||
export function drawTemporalEmphasisLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
visibleNodes: VisibleNodeSample[],
|
||||
temporalState: GraphTemporalState | null | undefined,
|
||||
interactionState: GraphInteractionState,
|
||||
effectsState: GraphEffectsState,
|
||||
) {
|
||||
if (!effectsState.temporalEmphasisEnabled || !temporalState?.currentTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.temporalEmphasis.minZoomTier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
visibleNodes
|
||||
.filter((sample) => isTemporalNodeActive(sample.attrs, temporalState.currentTime))
|
||||
.slice(0, GRAPH_THEME.effects.temporalEmphasis.maxHighlights)
|
||||
.forEach((sample) => {
|
||||
drawGlowHalo(
|
||||
context,
|
||||
sample.point.x,
|
||||
sample.point.y,
|
||||
Math.max(sample.size * GRAPH_THEME.effects.temporalEmphasis.radiusMultiplier, 14),
|
||||
withAlpha(GRAPH_THEME.palette.accent.temporal, GRAPH_THEME.effects.temporalEmphasis.glowAlpha),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function drawLensLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
sigma: Sigma,
|
||||
primaryNodeId: string,
|
||||
focusIds: Set<string>,
|
||||
) {
|
||||
const primaryData = sigma.getNodeDisplayData(primaryNodeId);
|
||||
if (!primaryData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const center = sigma.graphToViewport({ x: primaryData.x, y: primaryData.y });
|
||||
drawGlowHalo(
|
||||
context,
|
||||
center.x,
|
||||
center.y,
|
||||
GRAPH_THEME.effects.lens.radius,
|
||||
withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.glowAlpha),
|
||||
);
|
||||
|
||||
focusIds.forEach((neighborId) => {
|
||||
if (neighborId === primaryNodeId || !graph.hasNode(neighborId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!graph.hasDirectedEdge(primaryNodeId, neighborId)
|
||||
&& !graph.hasDirectedEdge(neighborId, primaryNodeId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const neighborData = sigma.getNodeDisplayData(neighborId);
|
||||
if (!neighborData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const neighborPoint = sigma.graphToViewport({ x: neighborData.x, y: neighborData.y });
|
||||
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.edgeAlpha * 0.38);
|
||||
context.lineWidth = GRAPH_THEME.effects.lens.edgeLineWidth + 3.2;
|
||||
context.lineCap = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(center.x, center.y);
|
||||
context.lineTo(neighborPoint.x, neighborPoint.y);
|
||||
context.stroke();
|
||||
|
||||
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.edgeAlpha);
|
||||
context.lineWidth = GRAPH_THEME.effects.lens.edgeLineWidth;
|
||||
context.beginPath();
|
||||
context.moveTo(center.x, center.y);
|
||||
context.lineTo(neighborPoint.x, neighborPoint.y);
|
||||
context.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
export function drawPathEffectsLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
segments: PathSegmentOverlay[],
|
||||
effectsState: GraphEffectsState,
|
||||
effectAvailability: {
|
||||
pathPulse: { available: boolean };
|
||||
pathFlow: { available: boolean };
|
||||
},
|
||||
now: number,
|
||||
) {
|
||||
if (effectsState.pathFlowEnabled && effectAvailability.pathFlow.available) {
|
||||
segments.forEach((segment, index) => {
|
||||
const t = ((now * GRAPH_THEME.effects.pathFlow.speed) + index * GRAPH_THEME.effects.pathFlow.spacing) % 1;
|
||||
const headX = segment.source.x + (segment.target.x - segment.source.x) * t;
|
||||
const headY = segment.source.y + (segment.target.y - segment.source.y) * t;
|
||||
const tailT = Math.max(0, t - 0.08);
|
||||
const tailX = segment.source.x + (segment.target.x - segment.source.x) * tailT;
|
||||
const tailY = segment.source.y + (segment.target.y - segment.source.y) * tailT;
|
||||
|
||||
context.strokeStyle = withAlpha(segment.color, 0.28);
|
||||
context.lineWidth = Math.max(segment.size + 3.6, 4.6);
|
||||
context.lineCap = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(tailX, tailY);
|
||||
context.lineTo(headX, headY);
|
||||
context.stroke();
|
||||
|
||||
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.selected, GRAPH_THEME.effects.pathFlow.opacity);
|
||||
context.lineWidth = Math.max(segment.size + 1.3, 2.4);
|
||||
context.beginPath();
|
||||
context.moveTo(tailX, tailY);
|
||||
context.lineTo(headX, headY);
|
||||
context.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
if (effectsState.pathPulseEnabled && effectAvailability.pathPulse.available) {
|
||||
segments.forEach((segment, index) => {
|
||||
const t = ((now * GRAPH_THEME.effects.pathPulse.speed) + index * 0.17) % 1;
|
||||
const x = segment.source.x + (segment.target.x - segment.source.x) * t;
|
||||
const y = segment.source.y + (segment.target.y - segment.source.y) * t;
|
||||
const glow = context.createRadialGradient(x, y, 0, x, y, GRAPH_THEME.effects.pathPulse.radius);
|
||||
glow.addColorStop(0, withAlpha(GRAPH_THEME.palette.accent.path, GRAPH_THEME.effects.pathPulse.glowAlpha));
|
||||
glow.addColorStop(1, "rgba(0,0,0,0)");
|
||||
context.fillStyle = glow;
|
||||
context.beginPath();
|
||||
context.arc(x, y, GRAPH_THEME.effects.pathPulse.radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
import Graph from "graphology";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import {
|
||||
clamp,
|
||||
blendHex,
|
||||
GRAPH_THEME,
|
||||
type GraphArrowVisibilityPolicy,
|
||||
type GraphBadgeKind,
|
||||
type GraphEdgeVariant,
|
||||
type GraphEdgeVisualState,
|
||||
type GraphLabelVisibilityPolicy,
|
||||
type GraphNodeShapeVariant,
|
||||
type GraphNodeVisualState,
|
||||
type GraphTheme,
|
||||
type GraphZoomTier,
|
||||
withAlpha,
|
||||
zoomTierAtLeast,
|
||||
} from "./graphTheme";
|
||||
import type { GraphInteractionState, GraphViewMode } from "./types";
|
||||
|
||||
const MAX_FOCUS_NEIGHBORS = GRAPH_THEME.focus.maxNeighbors;
|
||||
const FOCUS_RING_CAPACITY = GRAPH_THEME.focus.ringCapacity;
|
||||
const FOCUS_RING_GAP = GRAPH_THEME.focus.ringGap;
|
||||
const FOCUS_PRIMARY_LABELS = GRAPH_THEME.focus.primaryLabels;
|
||||
|
||||
function getOverviewPresenceBoost(cameraRatio: number) {
|
||||
return clamp(0, Math.log2(Math.max(cameraRatio, 1)) / 1.85, 1);
|
||||
}
|
||||
|
||||
export type GraphSigmaEdgeType = "line" | "arrow" | "curve" | "curvedArrow";
|
||||
|
||||
export type ResolvedNodeStyle = {
|
||||
color: string;
|
||||
shellColor: string;
|
||||
coreScale: number;
|
||||
size: number;
|
||||
forceLabel: boolean;
|
||||
label: string;
|
||||
zIndex: number;
|
||||
hidden: boolean;
|
||||
borderColor: string;
|
||||
borderSize: number;
|
||||
nodeVariant: GraphNodeShapeVariant;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeCount?: number;
|
||||
showBadge: boolean;
|
||||
showRing: boolean;
|
||||
ringColor?: string;
|
||||
ringSize: number;
|
||||
showHalo: boolean;
|
||||
haloColor: string;
|
||||
};
|
||||
|
||||
export type ResolvedEdgeStyle = {
|
||||
hidden: boolean;
|
||||
type?: GraphSigmaEdgeType;
|
||||
color?: string;
|
||||
size?: number;
|
||||
zIndex: number;
|
||||
edgeVariant: GraphEdgeVariant;
|
||||
arrowVisibilityPolicy: GraphArrowVisibilityPolicy;
|
||||
curveStrength: number;
|
||||
curvature: number;
|
||||
};
|
||||
|
||||
function forEachDirectedEdgeBetween(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
source: string,
|
||||
target: string,
|
||||
callback: (edgeId: string, attrs: EdgeAttributes) => void,
|
||||
) {
|
||||
graphRef.forEachDirectedEdge(source, target, (edgeId, attrs) => {
|
||||
callback(String(edgeId), attrs as EdgeAttributes);
|
||||
});
|
||||
}
|
||||
|
||||
function collectDirectedEdgeIdsBetween(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
source: string,
|
||||
target: string,
|
||||
): string[] {
|
||||
const edgeIds: string[] = [];
|
||||
forEachDirectedEdgeBetween(graphRef, source, target, (edgeId) => {
|
||||
edgeIds.push(edgeId);
|
||||
});
|
||||
return edgeIds;
|
||||
}
|
||||
|
||||
export function buildPathEdgeSet(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
path: string[],
|
||||
pathEdgeIds: string[] = [],
|
||||
): Set<string> {
|
||||
if (pathEdgeIds.length > 0) {
|
||||
return new Set<string>(pathEdgeIds.filter((edgeId) => graphRef.hasEdge(edgeId)));
|
||||
}
|
||||
|
||||
const edgeIds = new Set<string>();
|
||||
for (let index = 0; index < path.length - 1; index += 1) {
|
||||
collectDirectedEdgeIdsBetween(graphRef, path[index], path[index + 1]).forEach((edgeId) => edgeIds.add(edgeId));
|
||||
}
|
||||
return edgeIds;
|
||||
}
|
||||
|
||||
export function buildEdgeEndpointSet(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
...edgeIds: Array<string | null | undefined>
|
||||
): Set<string> {
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
edgeIds.forEach((edgeId) => {
|
||||
if (!edgeId || !graphRef.hasEdge(edgeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [source, target] = graphRef.extremities(edgeId);
|
||||
nodeIds.add(source);
|
||||
nodeIds.add(target);
|
||||
});
|
||||
|
||||
return nodeIds;
|
||||
}
|
||||
|
||||
function collectFocusEdgeIds(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
nodeIds: Set<string>,
|
||||
): Set<string> {
|
||||
const edgeIds = new Set<string>();
|
||||
const ids = Array.from(nodeIds);
|
||||
|
||||
for (let sourceIndex = 0; sourceIndex < ids.length; sourceIndex += 1) {
|
||||
const source = ids[sourceIndex];
|
||||
|
||||
for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) {
|
||||
const target = ids[targetIndex];
|
||||
if (source === target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
collectDirectedEdgeIdsBetween(graphRef, source, target).forEach((edgeId) => edgeIds.add(edgeId));
|
||||
}
|
||||
}
|
||||
|
||||
return edgeIds;
|
||||
}
|
||||
|
||||
function collectImpactedNodeIds(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
interactionState: GraphInteractionState | null,
|
||||
): Set<string> {
|
||||
if (!interactionState) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const impacted = new Set<string>(interactionState.activePath);
|
||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||
buildFocusSet(primaryNodeId).forEach((nodeId) => impacted.add(nodeId));
|
||||
}
|
||||
|
||||
buildEdgeEndpointSet(graphRef, interactionState.selectedEdgeId)
|
||||
.forEach((nodeId) => impacted.add(nodeId));
|
||||
|
||||
return impacted;
|
||||
}
|
||||
|
||||
function collectImpactedEdgeKeys(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
interactionState: GraphInteractionState | null,
|
||||
): Set<string> {
|
||||
if (!interactionState) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const impacted = new Set<string>(buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds));
|
||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||
collectFocusEdgeIds(graphRef, buildFocusSet(primaryNodeId)).forEach((edgeId) => impacted.add(edgeId));
|
||||
}
|
||||
|
||||
if (interactionState.selectedEdgeId) {
|
||||
impacted.add(interactionState.selectedEdgeId);
|
||||
}
|
||||
|
||||
return impacted;
|
||||
}
|
||||
|
||||
function resolveDisplayEdgeIds(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
stableEdgeIds: Set<string>,
|
||||
): string[] {
|
||||
return Array.from(stableEdgeIds).filter((edgeId) => graphRef.hasEdge(edgeId));
|
||||
}
|
||||
|
||||
export function collectInteractionRefreshTargets(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
previousState: GraphInteractionState | null,
|
||||
nextState: GraphInteractionState,
|
||||
): { nodes: string[]; edges: string[] } {
|
||||
const nodeIds = new Set<string>();
|
||||
const edgeKeys = new Set<string>();
|
||||
|
||||
collectImpactedNodeIds(graphRef, previousState).forEach((nodeId) => nodeIds.add(nodeId));
|
||||
collectImpactedNodeIds(graphRef, nextState).forEach((nodeId) => nodeIds.add(nodeId));
|
||||
collectImpactedEdgeKeys(graphRef, previousState).forEach((edgeId) => edgeKeys.add(edgeId));
|
||||
collectImpactedEdgeKeys(graphRef, nextState).forEach((edgeId) => edgeKeys.add(edgeId));
|
||||
|
||||
return {
|
||||
nodes: Array.from(nodeIds).filter((nodeId) => graphRef.hasNode(nodeId)),
|
||||
edges: resolveDisplayEdgeIds(graphRef, edgeKeys),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEdgeWeightBetween(source: string, target: string): number {
|
||||
let weight = 0;
|
||||
|
||||
forEachDirectedEdgeBetween(graph, source, target, (_edgeId, attrs) => {
|
||||
weight = Math.max(weight, Number(attrs?.weight ?? 0));
|
||||
});
|
||||
|
||||
forEachDirectedEdgeBetween(graph, target, source, (_edgeId, attrs) => {
|
||||
weight = Math.max(weight, Number(attrs?.weight ?? 0));
|
||||
});
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
export function rankNeighbors(nodeId: string): string[] {
|
||||
return graph
|
||||
.neighbors(nodeId)
|
||||
.map((neighborId) => ({
|
||||
id: neighborId,
|
||||
weight: getEdgeWeightBetween(nodeId, neighborId),
|
||||
degree: graph.degree(neighborId),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (right.weight !== left.weight) {
|
||||
return right.weight - left.weight;
|
||||
}
|
||||
if (right.degree !== left.degree) {
|
||||
return right.degree - left.degree;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
})
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
export function buildFocusSet(nodeId: string): Set<string> {
|
||||
const ranked = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
|
||||
return new Set<string>([nodeId, ...ranked]);
|
||||
}
|
||||
|
||||
export function isEdgeInteractable(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
interactionState: GraphInteractionState,
|
||||
edgeId: string,
|
||||
source: string,
|
||||
target: string,
|
||||
attrs: EdgeAttributes,
|
||||
): boolean {
|
||||
const pathEdgeIds = buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds);
|
||||
if (pathEdgeIds.has(edgeId) || interactionState.selectedEdgeId === edgeId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const primaryNodeId = interactionState.selectedNodeId;
|
||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||
if (source === primaryNodeId || target === primaryNodeId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const focusIds = buildFocusSet(primaryNodeId);
|
||||
return focusIds.has(source) && focusIds.has(target);
|
||||
}
|
||||
|
||||
if (interactionState.zoomTier !== "inspection") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Number(attrs.visualPriority ?? 0) >= GRAPH_THEME.zoomTiers[interactionState.zoomTier].edgePriorityThreshold;
|
||||
}
|
||||
|
||||
function resolveNodeColor(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
cameraRatio: number,
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||
const overviewTint = state === "neighbor"
|
||||
? theme.palette.overview.nodeTintMix + 0.09
|
||||
: theme.palette.overview.nodeTintMix;
|
||||
const overviewCore = blendHex(
|
||||
theme.palette.overview.nodeCore,
|
||||
semanticColor,
|
||||
Math.min(0.74, theme.palette.overview.nodeCoreMix + overviewTint),
|
||||
);
|
||||
|
||||
switch (theme.nodes.states[state].color) {
|
||||
case "selected":
|
||||
return theme.palette.accent.selected;
|
||||
case "hovered":
|
||||
return theme.palette.accent.hovered;
|
||||
case "path":
|
||||
return theme.palette.accent.path;
|
||||
case "muted":
|
||||
return zoomTier === "overview"
|
||||
? withAlpha(theme.palette.overview.nodeMuted, 0.42)
|
||||
: String(attrs.mutedColor || withAlpha(semanticColor, theme.nodes.mutedAlpha));
|
||||
case "base":
|
||||
default:
|
||||
if (zoomTier === "overview") {
|
||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||
const boostedCore = blendHex(overviewCore, semanticColor, 0.3 + 0.26 * presenceBoost);
|
||||
return withAlpha(boostedCore, Math.min(0.98, theme.palette.overview.nodeCoreAlpha + presenceBoost * 0.18));
|
||||
}
|
||||
return semanticColor;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeShellColor(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
cameraRatio: number,
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||
const overviewShell = blendHex(
|
||||
theme.palette.overview.nodeBase,
|
||||
semanticColor,
|
||||
(state === "neighbor" ? 0.02 : 0.012) + presenceBoost * 0.024,
|
||||
);
|
||||
|
||||
if (zoomTier !== "overview") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, semanticColor, 0.26), 0.95);
|
||||
}
|
||||
|
||||
if (state === "selected") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.selected, 0.05), 0.98);
|
||||
}
|
||||
|
||||
if (state === "hovered") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.hovered, 0.06), 0.98);
|
||||
}
|
||||
|
||||
if (state === "path") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.path, 0.06), 0.97);
|
||||
}
|
||||
|
||||
if (state === "muted" || state === "inactive") {
|
||||
return withAlpha(theme.palette.overview.nodeMuted, 0.22);
|
||||
}
|
||||
|
||||
return withAlpha(overviewShell, theme.palette.overview.nodeShellAlpha);
|
||||
}
|
||||
|
||||
function resolveNodeCoreScale(
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
cameraRatio: number,
|
||||
) {
|
||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||
if (zoomTier === "overview") {
|
||||
switch (state) {
|
||||
case "selected":
|
||||
return 0.34 + presenceBoost * 0.08;
|
||||
case "hovered":
|
||||
return 0.32 + presenceBoost * 0.08;
|
||||
case "path":
|
||||
return 0.28 + presenceBoost * 0.07;
|
||||
case "neighbor":
|
||||
return 0.2 + presenceBoost * 0.06;
|
||||
case "muted":
|
||||
case "inactive":
|
||||
return 0.08 + presenceBoost * 0.03;
|
||||
case "default":
|
||||
default:
|
||||
return 0.16 + presenceBoost * 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case "selected":
|
||||
return 0.52;
|
||||
case "hovered":
|
||||
return 0.48;
|
||||
case "path":
|
||||
return 0.44;
|
||||
case "neighbor":
|
||||
return 0.3;
|
||||
case "muted":
|
||||
case "inactive":
|
||||
return 0.1;
|
||||
case "default":
|
||||
default:
|
||||
return zoomTier === "structure" ? 0.22 : 0.28;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEdgeColor(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphEdgeVisualState,
|
||||
attrs: EdgeAttributes,
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const defaultInspectionColor = zoomTier === "overview"
|
||||
? theme.palette.overview.edgeInspection
|
||||
: theme.palette.muted.edgeInspection;
|
||||
const baseColor = String(attrs.baseColor || fallbackColor || defaultInspectionColor);
|
||||
|
||||
switch (theme.edges.states[state].color) {
|
||||
case "hover":
|
||||
return theme.palette.accent.hovered;
|
||||
case "path":
|
||||
return theme.palette.accent.path;
|
||||
case "backbone":
|
||||
return zoomTier === "overview"
|
||||
? theme.palette.overview.edgeBackbone
|
||||
: theme.palette.muted.edgeFocus;
|
||||
case "focus":
|
||||
return theme.palette.muted.edgeFocus;
|
||||
case "overview":
|
||||
return theme.palette.muted.edgeOverview;
|
||||
case "structure":
|
||||
return zoomTier === "overview"
|
||||
? theme.palette.overview.edgeStructure
|
||||
: theme.palette.muted.edgeStructure;
|
||||
case "inspection":
|
||||
return zoomTier === "overview"
|
||||
? theme.palette.overview.edgeInspection
|
||||
: theme.palette.muted.edgeInspection;
|
||||
case "muted":
|
||||
return zoomTier === "overview"
|
||||
? theme.palette.overview.edgeStructure
|
||||
: String(attrs.mutedColor || theme.palette.muted.edgeOverview);
|
||||
default:
|
||||
return baseColor;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeRingColor(
|
||||
theme: GraphTheme,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
) {
|
||||
if (state === "selected") {
|
||||
return attrs.ringColor || theme.nodes.selectedRing.color;
|
||||
}
|
||||
if (state === "hovered") {
|
||||
return theme.palette.accent.hovered;
|
||||
}
|
||||
if (state === "path") {
|
||||
return theme.palette.accent.path;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveNodeRingSize(
|
||||
theme: GraphTheme,
|
||||
state: GraphNodeVisualState,
|
||||
zoomTier: GraphZoomTier,
|
||||
) {
|
||||
if (state === "selected" && zoomTierAtLeast(zoomTier, theme.nodes.selectedRing.visibleFrom)) {
|
||||
return theme.nodes.selectedRing.nativeSize;
|
||||
}
|
||||
if (state === "hovered") {
|
||||
return Math.max(1.45, theme.nodes.selectedRing.nativeSize - 0.35);
|
||||
}
|
||||
if (state === "path") {
|
||||
return Math.max(1.2, theme.nodes.selectedRing.nativeSize - 0.55);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function resolveNodeVisualState(
|
||||
nodeId: string,
|
||||
zoomTier: GraphZoomTier,
|
||||
hoveredNodeId: string | null,
|
||||
selectedNodeId: string,
|
||||
selectedEdgeId: string,
|
||||
focusIds: Set<string>,
|
||||
edgeEndpointIds: Set<string>,
|
||||
pathNodeIds: Set<string>,
|
||||
): GraphNodeVisualState {
|
||||
if (hoveredNodeId && nodeId === hoveredNodeId) {
|
||||
return "hovered";
|
||||
}
|
||||
if (selectedNodeId && nodeId === selectedNodeId) {
|
||||
return "selected";
|
||||
}
|
||||
if (pathNodeIds.has(nodeId)) {
|
||||
return "path";
|
||||
}
|
||||
if (focusIds.has(nodeId)) {
|
||||
return "neighbor";
|
||||
}
|
||||
if (edgeEndpointIds.has(nodeId)) {
|
||||
return "neighbor";
|
||||
}
|
||||
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathNodeIds.size > 0) {
|
||||
if (zoomTier === "overview") {
|
||||
return "default";
|
||||
}
|
||||
return "muted";
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function resolveEdgeVisualState(
|
||||
edgeId: string,
|
||||
source: string,
|
||||
target: string,
|
||||
zoomTier: GraphZoomTier,
|
||||
hoveredNodeId: string | null,
|
||||
selectedNodeId: string,
|
||||
selectedEdgeId: string,
|
||||
focusIds: Set<string>,
|
||||
pathEdgeIds: Set<string>,
|
||||
overviewBackboneEdgeIds: Set<string>,
|
||||
): GraphEdgeVisualState {
|
||||
const primaryNodeId = hoveredNodeId || selectedNodeId;
|
||||
|
||||
if (pathEdgeIds.has(edgeId)) {
|
||||
return "path";
|
||||
}
|
||||
|
||||
if (selectedEdgeId && edgeId === selectedEdgeId) {
|
||||
return "selected";
|
||||
}
|
||||
|
||||
if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
|
||||
return hoveredNodeId ? "hovered" : "selected";
|
||||
}
|
||||
|
||||
if (zoomTier !== "overview" && focusIds.has(source) && focusIds.has(target)) {
|
||||
return "neighbor";
|
||||
}
|
||||
|
||||
if (zoomTier === "overview" && overviewBackboneEdgeIds.has(edgeId)) {
|
||||
return "backbone";
|
||||
}
|
||||
|
||||
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathEdgeIds.size > 0) {
|
||||
return "muted";
|
||||
}
|
||||
|
||||
if (zoomTier === "overview") {
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function resolveNodeVariant(state: GraphNodeVisualState, attrs: NodeAttributes): GraphNodeShapeVariant {
|
||||
if (state === "selected") {
|
||||
return "selected";
|
||||
}
|
||||
|
||||
return attrs.nodeShapeVariant || attrs.nodeVariant || "default";
|
||||
}
|
||||
|
||||
export function resolveEdgeVariant(state: GraphEdgeVisualState, attrs: EdgeAttributes): GraphEdgeVariant {
|
||||
if (state === "path") {
|
||||
return "pathSignal";
|
||||
}
|
||||
|
||||
if ((attrs.parallelCount ?? 1) > 1) {
|
||||
return "parallelCurve";
|
||||
}
|
||||
|
||||
if (attrs.edgeVariant) {
|
||||
return attrs.edgeVariant;
|
||||
}
|
||||
|
||||
if (attrs.isBidirectional) {
|
||||
return "bidirectionalCurve";
|
||||
}
|
||||
|
||||
if (attrs.arrowVisibilityPolicy === "contextual") {
|
||||
return "directional";
|
||||
}
|
||||
|
||||
return "line";
|
||||
}
|
||||
|
||||
export function shouldForceNodeLabel(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
labelPriority: number,
|
||||
): boolean {
|
||||
const tierConfig = theme.zoomTiers[zoomTier];
|
||||
const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
|
||||
const policy = attrs.labelVisibilityPolicy || "priority";
|
||||
|
||||
if (forceVisibleState || theme.nodes.states[state].forceLabel) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (policy as GraphLabelVisibilityPolicy) {
|
||||
case "always":
|
||||
return true;
|
||||
case "local":
|
||||
return zoomTier !== "overview" && state !== "default" && state !== "muted" && state !== "inactive";
|
||||
case "priority":
|
||||
return labelPriority >= tierConfig.labelThreshold;
|
||||
case "none":
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeBorderColor(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
variant: GraphNodeShapeVariant,
|
||||
attrs: NodeAttributes,
|
||||
baseColor: string,
|
||||
) {
|
||||
if (state === "selected" || variant === "selected") {
|
||||
return attrs.ringColor || theme.nodes.selectedRing.color;
|
||||
}
|
||||
if (state === "hovered") {
|
||||
return theme.palette.accent.hovered;
|
||||
}
|
||||
if (state === "path") {
|
||||
return theme.palette.accent.path;
|
||||
}
|
||||
if (state === "muted" || state === "inactive") {
|
||||
return withAlpha(
|
||||
attrs.strokeColor || attrs.borderColor || theme.palette.overview.nodeBorder || theme.palette.background.nodeBorder,
|
||||
zoomTier === "overview" ? 0.26 : 0.7,
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "temporal") {
|
||||
return theme.palette.accent.temporal;
|
||||
}
|
||||
if (variant === "provenance") {
|
||||
return theme.palette.accent.provenance;
|
||||
}
|
||||
if (variant === "inferred") {
|
||||
return theme.palette.accent.inferred;
|
||||
}
|
||||
|
||||
if (zoomTier === "overview") {
|
||||
return withAlpha(
|
||||
blendHex(theme.palette.overview.nodeBorder, baseColor, state === "neighbor" ? 0.08 : 0.03),
|
||||
state === "neighbor" ? 0.24 : 0.06,
|
||||
);
|
||||
}
|
||||
|
||||
return attrs.strokeColor || attrs.borderColor || theme.palette.background.nodeBorder || baseColor;
|
||||
}
|
||||
|
||||
export function resolveNodeElementStyle(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
label: string,
|
||||
cameraRatio = 1,
|
||||
): ResolvedNodeStyle {
|
||||
const tierConfig = theme.zoomTiers[zoomTier];
|
||||
const stateConfig = theme.nodes.states[state];
|
||||
const nodeVariant = resolveNodeVariant(state, attrs);
|
||||
const variantConfig = theme.nodes.variants[nodeVariant];
|
||||
const baseSize = Number(attrs.baseSize || attrs.size || 4);
|
||||
const labelPriority = Number(attrs.labelPriority ?? 0);
|
||||
const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
||||
const shellColor = resolveNodeShellColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
||||
const sizeMultiplier = (state === "default" ? tierConfig.nodeScale : stateConfig.sizeMultiplier) * variantConfig.sizeMultiplier;
|
||||
const overviewPresence = zoomTier === "overview" ? 1 + getOverviewPresenceBoost(cameraRatio) * 1.2 : 1;
|
||||
const forceLabel = shouldForceNodeLabel(theme, zoomTier, state, attrs, labelPriority);
|
||||
const badgeKind = attrs.badgeKind || variantConfig.badgeKind;
|
||||
const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
|
||||
const showBadge = Boolean(
|
||||
badgeKind
|
||||
&& (forceVisibleState || (tierConfig.showBadges && zoomTierAtLeast(zoomTier, variantConfig.badgeVisibleFrom)))
|
||||
&& state !== "muted"
|
||||
&& state !== "inactive",
|
||||
);
|
||||
const ringSize = resolveNodeRingSize(theme, state, zoomTier);
|
||||
const ringColor = resolveNodeRingColor(theme, state, attrs);
|
||||
const showRing = ringSize > 0;
|
||||
const showHalo = state === "hovered" || state === "selected" || state === "path";
|
||||
const strokeBase = state === "muted" || state === "inactive"
|
||||
? theme.nodes.strokeHierarchy[zoomTier].muted
|
||||
: forceVisibleState
|
||||
? theme.nodes.strokeHierarchy[zoomTier].emphasis
|
||||
: theme.nodes.strokeHierarchy[zoomTier].base;
|
||||
|
||||
return {
|
||||
color,
|
||||
shellColor,
|
||||
coreScale: resolveNodeCoreScale(zoomTier, state, cameraRatio),
|
||||
size: Math.max(baseSize * sizeMultiplier * overviewPresence, stateConfig.minSize),
|
||||
forceLabel,
|
||||
label: forceLabel ? label : "",
|
||||
zIndex: forceLabel && stateConfig.zIndex === 0 ? 1 : stateConfig.zIndex,
|
||||
hidden: false,
|
||||
borderColor: resolveNodeBorderColor(theme, zoomTier, state, nodeVariant, attrs, color),
|
||||
borderSize: Math.max(
|
||||
0.4,
|
||||
Number(attrs.borderSize ?? 0.85) + strokeBase + stateConfig.borderBoost + variantConfig.borderBoost - 0.8,
|
||||
),
|
||||
nodeVariant,
|
||||
badgeKind,
|
||||
badgeCount: attrs.badgeCount,
|
||||
showBadge,
|
||||
showRing,
|
||||
ringColor,
|
||||
ringSize,
|
||||
showHalo,
|
||||
haloColor: attrs.haloColor || attrs.glowColor || withAlpha(color, theme.overlays.hoverGlowAlpha + variantConfig.haloBoost),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStraightEdgeType(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphEdgeVisualState,
|
||||
variant: GraphEdgeVariant,
|
||||
attrs: EdgeAttributes,
|
||||
): "line" | "arrow" {
|
||||
const variantConfig = theme.edges.variants[variant];
|
||||
|
||||
if (theme.edges.states[state].forceArrow || variantConfig.arrowPolicy === "always") {
|
||||
return "arrow";
|
||||
}
|
||||
|
||||
if (variantConfig.arrowPolicy === "contextual" && theme.zoomTiers[zoomTier].showContextualArrows) {
|
||||
return "arrow";
|
||||
}
|
||||
|
||||
if (state !== "default") {
|
||||
return (attrs.type as "line" | "arrow" | undefined) || variantConfig.baseType;
|
||||
}
|
||||
|
||||
return Number(attrs.visualPriority ?? 0) >= theme.zoomTiers[zoomTier].arrowPriorityThreshold && theme.zoomTiers[zoomTier].showContextualArrows
|
||||
? "arrow"
|
||||
: "line";
|
||||
}
|
||||
|
||||
function resolveEdgeCurvature(
|
||||
theme: GraphTheme,
|
||||
state: GraphEdgeVisualState,
|
||||
edgeVariant: GraphEdgeVariant,
|
||||
attrs: EdgeAttributes,
|
||||
sourceId: string | undefined,
|
||||
targetId: string | undefined,
|
||||
) {
|
||||
const variantConfig = theme.edges.variants[edgeVariant];
|
||||
const baseCurvature = edgeVariant === "line" && state === "selected"
|
||||
? Math.max(variantConfig.curveStrength, 0.14)
|
||||
: variantConfig.curveStrength;
|
||||
|
||||
if (baseCurvature === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof attrs.parallelCount === "number" && attrs.parallelCount > 1 && typeof attrs.parallelIndex === "number") {
|
||||
const center = (attrs.parallelCount - 1) / 2;
|
||||
return (attrs.parallelIndex - center) * baseCurvature;
|
||||
}
|
||||
|
||||
if ((edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve" || attrs.isBidirectional) && sourceId && targetId) {
|
||||
return sourceId.localeCompare(targetId) <= 0 ? baseCurvature : -baseCurvature;
|
||||
}
|
||||
|
||||
return baseCurvature;
|
||||
}
|
||||
|
||||
export function resolveEdgeElementStyle(
|
||||
theme: GraphTheme,
|
||||
zoomTier: GraphZoomTier,
|
||||
state: GraphEdgeVisualState,
|
||||
attrs: EdgeAttributes,
|
||||
sourceId?: string,
|
||||
targetId?: string,
|
||||
): ResolvedEdgeStyle {
|
||||
const tierConfig = theme.zoomTiers[zoomTier];
|
||||
const stateConfig = theme.edges.states[state];
|
||||
const edgeVariant = resolveEdgeVariant(state, attrs);
|
||||
const variantConfig = theme.edges.variants[edgeVariant];
|
||||
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
|
||||
const visualPriority = Number(attrs.visualPriority ?? 0);
|
||||
const belowPriorityThreshold = state === "default"
|
||||
&& visualPriority < tierConfig.edgePriorityThreshold
|
||||
&& edgeVariant === "line";
|
||||
|
||||
if (stateConfig.hide || belowPriorityThreshold) {
|
||||
return {
|
||||
hidden: true,
|
||||
zIndex: 0,
|
||||
edgeVariant,
|
||||
arrowVisibilityPolicy: variantConfig.arrowPolicy,
|
||||
curveStrength: variantConfig.curveStrength,
|
||||
curvature: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sizeMultiplier = (state === "default" ? tierConfig.edgeSizeScale : stateConfig.sizeMultiplier) * variantConfig.sizeMultiplier;
|
||||
const straightType = resolveStraightEdgeType(theme, zoomTier, state, edgeVariant, attrs);
|
||||
const useCurvedRenderer = tierConfig.showCurves
|
||||
&& zoomTier !== "overview"
|
||||
&& (
|
||||
edgeVariant === "pathSignal"
|
||||
|| state === "selected"
|
||||
|| ((state === "neighbor" || state === "hovered") && (edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve"))
|
||||
);
|
||||
const curvature = useCurvedRenderer
|
||||
? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
hidden: false,
|
||||
type: useCurvedRenderer
|
||||
? (straightType === "arrow" ? "curvedArrow" : "curve")
|
||||
: straightType,
|
||||
color: resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color),
|
||||
size: Math.max(baseSize * sizeMultiplier, stateConfig.minSize),
|
||||
zIndex: stateConfig.zIndex,
|
||||
edgeVariant,
|
||||
arrowVisibilityPolicy: variantConfig.arrowPolicy,
|
||||
curveStrength: variantConfig.curveStrength,
|
||||
curvature,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFocusedGraph(
|
||||
nodeId: string,
|
||||
activePath: string[],
|
||||
activePathEdgeIds: string[] = [],
|
||||
): Graph<NodeAttributes, EdgeAttributes> {
|
||||
const focused = new Graph<NodeAttributes, EdgeAttributes>({
|
||||
type: "directed",
|
||||
multi: true,
|
||||
allowSelfLoops: false,
|
||||
});
|
||||
|
||||
const rankedNeighbors = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
|
||||
const focusIds = new Set<string>([nodeId, ...rankedNeighbors]);
|
||||
const labelledNeighborIds = new Set(rankedNeighbors.slice(0, FOCUS_PRIMARY_LABELS));
|
||||
const pathNodeIds = new Set(activePath);
|
||||
const pathEdgeIds = buildPathEdgeSet(graph, activePath, activePathEdgeIds);
|
||||
|
||||
const addNode = (id: string, attrs: NodeAttributes) => {
|
||||
if (!focused.hasNode(id)) {
|
||||
focused.addNode(id, attrs);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedAttrs = graph.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
const selectedState = resolveNodeElementStyle(GRAPH_THEME, "inspection", "selected", selectedAttrs, selectedAttrs.label);
|
||||
addNode(nodeId, {
|
||||
...selectedAttrs,
|
||||
x: 0,
|
||||
y: 0,
|
||||
color: selectedState.color,
|
||||
size: Math.max(selectedState.size, 22),
|
||||
baseColor: selectedState.color,
|
||||
baseSize: Math.max(selectedState.size, 22),
|
||||
label: selectedState.label,
|
||||
});
|
||||
|
||||
rankedNeighbors.forEach((neighborId, index) => {
|
||||
const baseAttrs = graph.getNodeAttributes(neighborId) as NodeAttributes;
|
||||
const ring = Math.floor(index / FOCUS_RING_CAPACITY);
|
||||
const ringIndex = index % FOCUS_RING_CAPACITY;
|
||||
const itemsInRing = Math.min(
|
||||
FOCUS_RING_CAPACITY,
|
||||
rankedNeighbors.length - ring * FOCUS_RING_CAPACITY,
|
||||
);
|
||||
const radius = FOCUS_RING_GAP * (ring + 1);
|
||||
const angle = (Math.PI * 2 * ringIndex) / itemsInRing - Math.PI / 2;
|
||||
const visualState: GraphNodeVisualState = pathNodeIds.has(neighborId)
|
||||
? "path"
|
||||
: labelledNeighborIds.has(neighborId)
|
||||
? "neighbor"
|
||||
: "default";
|
||||
const style = resolveNodeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
visualState,
|
||||
{
|
||||
...baseAttrs,
|
||||
labelPriority: labelledNeighborIds.has(neighborId) || pathNodeIds.has(neighborId)
|
||||
? Math.max(Number(baseAttrs.labelPriority ?? 0), 1)
|
||||
: 0,
|
||||
},
|
||||
baseAttrs.label,
|
||||
);
|
||||
|
||||
addNode(neighborId, {
|
||||
...baseAttrs,
|
||||
x: Math.cos(angle) * radius,
|
||||
y: Math.sin(angle) * radius,
|
||||
color: style.color,
|
||||
size: Math.max(style.size, 8.5),
|
||||
baseColor: style.color,
|
||||
baseSize: Math.max(style.size, 8.5),
|
||||
label: style.label,
|
||||
});
|
||||
});
|
||||
|
||||
for (const source of focusIds) {
|
||||
for (const target of focusIds) {
|
||||
if (source === target) {
|
||||
continue;
|
||||
}
|
||||
forEachDirectedEdgeBetween(graph, source, target, (edgeId, attrs) => {
|
||||
const state: GraphEdgeVisualState = pathEdgeIds.has(edgeId)
|
||||
? "path"
|
||||
: source === nodeId || target === nodeId
|
||||
? "selected"
|
||||
: "neighbor";
|
||||
const style = resolveEdgeElementStyle(GRAPH_THEME, "inspection", state, attrs, source, target);
|
||||
focused.mergeDirectedEdgeWithKey(edgeId, source, target, {
|
||||
...attrs,
|
||||
type: style.type,
|
||||
size: style.size,
|
||||
color: style.color,
|
||||
baseSize: style.size,
|
||||
baseColor: style.color,
|
||||
curvature: style.curvature,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return focused;
|
||||
}
|
||||
|
||||
export function resolveDisplayGraph(
|
||||
selectedNodeId: string,
|
||||
activePath: string[],
|
||||
activePathEdgeIds: string[],
|
||||
viewMode: GraphViewMode,
|
||||
) {
|
||||
const isFocusedView = viewMode === "focused" && Boolean(selectedNodeId) && graph.hasNode(selectedNodeId);
|
||||
return isFocusedView && selectedNodeId ? createFocusedGraph(selectedNodeId, activePath, activePathEdgeIds) : graph;
|
||||
}
|
||||
|
||||
export function createInteractionState(
|
||||
hoveredNodeId: string | null,
|
||||
selectedNodeId: string,
|
||||
selectedEdgeId: string,
|
||||
activePath: string[],
|
||||
activePathEdgeIds: string[],
|
||||
viewMode: GraphViewMode,
|
||||
zoomTier: GraphZoomTier,
|
||||
isLayoutRunning: boolean,
|
||||
): GraphInteractionState {
|
||||
return {
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
focusedNodeId: selectedNodeId,
|
||||
activePath,
|
||||
activePathEdgeIds,
|
||||
viewMode,
|
||||
zoomTier,
|
||||
isLayoutRunning,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
export type GraphZoomTier = "overview" | "structure" | "inspection";
|
||||
export type GraphNodeVisualState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphEdgeVisualState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphNodeShapeVariant = "default" | "temporal" | "inferred" | "provenance" | "selected";
|
||||
export type GraphEdgeVariant = "line" | "directional" | "bidirectionalCurve" | "parallelCurve" | "pathSignal";
|
||||
export type GraphArrowVisibilityPolicy = "hidden" | "contextual" | "always";
|
||||
export type GraphLabelVisibilityPolicy = "none" | "priority" | "local" | "always";
|
||||
export type GraphBadgeKind = "inferred" | "temporal" | "provenance";
|
||||
|
||||
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
|
||||
type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted";
|
||||
|
||||
export interface GraphTheme {
|
||||
palette: {
|
||||
semantic: string[];
|
||||
overview: {
|
||||
nodeBase: string;
|
||||
nodeCore: string;
|
||||
nodeMuted: string;
|
||||
nodeBorder: string;
|
||||
nodeTintMix: number;
|
||||
nodeCoreMix: number;
|
||||
nodeShellAlpha: number;
|
||||
nodeCoreAlpha: number;
|
||||
edgeBackbone: string;
|
||||
edgeStructure: string;
|
||||
edgeInspection: string;
|
||||
};
|
||||
accent: {
|
||||
selected: string;
|
||||
hovered: string;
|
||||
path: string;
|
||||
temporal: string;
|
||||
provenance: string;
|
||||
inferred: string;
|
||||
};
|
||||
muted: {
|
||||
fallback: string;
|
||||
nodeAlpha: number;
|
||||
edgeOverview: string;
|
||||
edgeStructure: string;
|
||||
edgeInspection: string;
|
||||
edgeFocus: string;
|
||||
};
|
||||
background: {
|
||||
canvas: string;
|
||||
shell: string;
|
||||
shellBorder: string;
|
||||
shellGlow: string;
|
||||
grid: string;
|
||||
vignette: string;
|
||||
nodeBorder: string;
|
||||
};
|
||||
};
|
||||
zoomTiers: Record<GraphZoomTier, {
|
||||
maxRatio: number;
|
||||
nodeScale: number;
|
||||
labelThreshold: number;
|
||||
labelBudget: number;
|
||||
edgePriorityThreshold: number;
|
||||
arrowPriorityThreshold: number;
|
||||
edgeSizeScale: number;
|
||||
showBadges: boolean;
|
||||
showCurves: boolean;
|
||||
showContextualArrows: boolean;
|
||||
}>;
|
||||
labels: {
|
||||
forceVisibleStates: readonly GraphNodeVisualState[];
|
||||
policies: Record<GraphLabelVisibilityPolicy, {
|
||||
minZoomTier: GraphZoomTier;
|
||||
}>;
|
||||
chip: {
|
||||
fontFamily: string;
|
||||
fontWeight: number;
|
||||
fontSize: number;
|
||||
maxFontSize: number;
|
||||
sizeScale: number;
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
radius: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
background: string;
|
||||
borderColor: string;
|
||||
borderAlpha: number;
|
||||
textColor: string;
|
||||
shadowColor: string;
|
||||
shadowAlpha: number;
|
||||
shadowBlur: number;
|
||||
};
|
||||
hoverCard: {
|
||||
fontFamily: string;
|
||||
titleWeight: number;
|
||||
titleSize: number;
|
||||
metaWeight: number;
|
||||
metaSize: number;
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
radius: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
metaGap: number;
|
||||
background: string;
|
||||
borderColor: string;
|
||||
borderAlpha: number;
|
||||
textColor: string;
|
||||
metaColor: string;
|
||||
shadowColor: string;
|
||||
shadowAlpha: number;
|
||||
shadowBlur: number;
|
||||
};
|
||||
};
|
||||
nodes: {
|
||||
backgroundScale: number;
|
||||
mutedAlpha: number;
|
||||
strokeHierarchy: Record<GraphZoomTier, {
|
||||
base: number;
|
||||
emphasis: number;
|
||||
muted: number;
|
||||
}>;
|
||||
states: Record<GraphNodeVisualState, {
|
||||
color: GraphNodeColorMode;
|
||||
sizeMultiplier: number;
|
||||
minSize: number;
|
||||
forceLabel: boolean;
|
||||
zIndex: number;
|
||||
borderBoost: number;
|
||||
}>;
|
||||
variants: Record<GraphNodeShapeVariant, {
|
||||
sizeMultiplier: number;
|
||||
borderBoost: number;
|
||||
haloBoost: number;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeVisibleFrom: GraphZoomTier;
|
||||
}>;
|
||||
selectedRing: {
|
||||
color: string;
|
||||
width: number;
|
||||
nativeSize: number;
|
||||
glowAlpha: number;
|
||||
visibleFrom: GraphZoomTier;
|
||||
};
|
||||
badges: Record<GraphBadgeKind, {
|
||||
color: string;
|
||||
label: string;
|
||||
}>;
|
||||
badge: {
|
||||
radius: number;
|
||||
offset: number;
|
||||
fontSize: number;
|
||||
textColor: string;
|
||||
background: string;
|
||||
stroke: string;
|
||||
glowAlpha: number;
|
||||
};
|
||||
};
|
||||
edges: {
|
||||
states: Record<GraphEdgeVisualState, {
|
||||
color: GraphEdgeColorMode;
|
||||
sizeMultiplier: number;
|
||||
minSize: number;
|
||||
zIndex: number;
|
||||
forceArrow: boolean;
|
||||
hide: boolean;
|
||||
}>;
|
||||
variants: Record<GraphEdgeVariant, {
|
||||
baseType: "line" | "arrow";
|
||||
arrowPolicy: GraphArrowVisibilityPolicy;
|
||||
curveStrength: number;
|
||||
sizeMultiplier: number;
|
||||
glowAlpha: number;
|
||||
}>;
|
||||
};
|
||||
overlays: {
|
||||
hoverGlowAlpha: number;
|
||||
pathGlowAlpha: number;
|
||||
glowRadiusMultiplier: number;
|
||||
minGlowRadius: number;
|
||||
pulseRadius: number;
|
||||
curveLineWidth: number;
|
||||
curveGlowWidth: number;
|
||||
badgeGlowRadius: number;
|
||||
};
|
||||
focus: {
|
||||
maxNeighbors: number;
|
||||
ringCapacity: number;
|
||||
ringGap: number;
|
||||
primaryLabels: number;
|
||||
};
|
||||
motion: {
|
||||
cameraMs: number;
|
||||
};
|
||||
effects: {
|
||||
pathPulse: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxSegments: number;
|
||||
speed: number;
|
||||
radius: number;
|
||||
glowAlpha: number;
|
||||
};
|
||||
pathFlow: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxSegments: number;
|
||||
speed: number;
|
||||
spacing: number;
|
||||
opacity: number;
|
||||
radius: number;
|
||||
};
|
||||
lens: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
radius: number;
|
||||
feather: number;
|
||||
glowAlpha: number;
|
||||
edgeAlpha: number;
|
||||
edgeLineWidth: number;
|
||||
};
|
||||
temporalEmphasis: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxHighlights: number;
|
||||
radiusMultiplier: number;
|
||||
glowAlpha: number;
|
||||
};
|
||||
semanticRegions: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxRegions: number;
|
||||
minVisibleSamples: number;
|
||||
fogResolutionScale: number;
|
||||
splatRadius: number;
|
||||
blurPasses: number;
|
||||
densityThreshold: number;
|
||||
contourThreshold: number;
|
||||
minMaskPixels: number;
|
||||
minOccupancyRatio: number;
|
||||
dominantMassRatio: number;
|
||||
outerContourMinMaskPixels: number;
|
||||
fogAlpha: number;
|
||||
innerContourAlpha: number;
|
||||
outerContourAlpha: number;
|
||||
};
|
||||
contours: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
maxContours: number;
|
||||
baseRadius: number;
|
||||
glowAlpha: number;
|
||||
};
|
||||
legend: {
|
||||
maxGroups: number;
|
||||
};
|
||||
diagnostics: {
|
||||
enabledInDev: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const GRAPH_THEME: GraphTheme = {
|
||||
palette: {
|
||||
semantic: [
|
||||
"#3E79F2",
|
||||
"#149287",
|
||||
"#2F9F61",
|
||||
"#555FD6",
|
||||
"#8A56D8",
|
||||
"#B65473",
|
||||
"#C9922E",
|
||||
],
|
||||
overview: {
|
||||
nodeBase: "#0B1320",
|
||||
nodeCore: "#435D7A",
|
||||
nodeMuted: "#121927",
|
||||
nodeBorder: "#64758C",
|
||||
nodeTintMix: 0.03,
|
||||
nodeCoreMix: 0.52,
|
||||
nodeShellAlpha: 0.97,
|
||||
nodeCoreAlpha: 1,
|
||||
edgeBackbone: "rgba(83, 111, 148, 0.04)",
|
||||
edgeStructure: "rgba(72, 90, 118, 0.009)",
|
||||
edgeInspection: "rgba(98, 120, 148, 0.026)",
|
||||
},
|
||||
accent: {
|
||||
selected: "#F2D288",
|
||||
hovered: "#8FE7FF",
|
||||
path: "#D79056",
|
||||
temporal: "#49D7FF",
|
||||
provenance: "#C9A5FF",
|
||||
inferred: "#D07B4D",
|
||||
},
|
||||
muted: {
|
||||
fallback: "rgba(96, 112, 136, 0.1)",
|
||||
nodeAlpha: 0.085,
|
||||
edgeOverview: "rgba(82, 100, 124, 0.009)",
|
||||
edgeStructure: "rgba(92, 112, 138, 0.02)",
|
||||
edgeInspection: "rgba(124, 148, 176, 0.066)",
|
||||
edgeFocus: "rgba(160, 186, 218, 0.16)",
|
||||
},
|
||||
background: {
|
||||
canvas: "#07101A",
|
||||
shell: "rgba(8, 15, 26, 0.8)",
|
||||
shellBorder: "rgba(118, 162, 207, 0.14)",
|
||||
shellGlow: "rgba(48, 88, 140, 0.14)",
|
||||
grid: "rgba(92, 126, 170, 0.034)",
|
||||
vignette: "rgba(2, 5, 11, 0.84)",
|
||||
nodeBorder: "#0C1522",
|
||||
},
|
||||
},
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.66,
|
||||
labelThreshold: 0.985,
|
||||
labelBudget: 10,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.34,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 0.98,
|
||||
labelThreshold: 0.88,
|
||||
labelBudget: 36,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
},
|
||||
inspection: {
|
||||
maxRatio: 0.5,
|
||||
nodeScale: 1,
|
||||
labelThreshold: 0.7,
|
||||
labelBudget: 80,
|
||||
edgePriorityThreshold: 0,
|
||||
arrowPriorityThreshold: 0.58,
|
||||
edgeSizeScale: 1.04,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
|
||||
policies: {
|
||||
none: { minZoomTier: "inspection" },
|
||||
priority: { minZoomTier: "overview" },
|
||||
local: { minZoomTier: "structure" },
|
||||
always: { minZoomTier: "overview" },
|
||||
},
|
||||
chip: {
|
||||
fontFamily: "\"IBM Plex Sans\", Inter, system-ui, sans-serif",
|
||||
fontWeight: 500,
|
||||
fontSize: 10,
|
||||
maxFontSize: 11,
|
||||
sizeScale: 0.25,
|
||||
paddingX: 6,
|
||||
paddingY: 3,
|
||||
radius: 6,
|
||||
offsetX: 12,
|
||||
offsetY: 10,
|
||||
background: "rgba(8, 14, 24, 0.9)",
|
||||
borderColor: "rgba(154, 181, 212, 0.16)",
|
||||
borderAlpha: 0.28,
|
||||
textColor: "#EAF3FF",
|
||||
shadowColor: "rgba(0, 0, 0, 0.6)",
|
||||
shadowAlpha: 0.26,
|
||||
shadowBlur: 12,
|
||||
},
|
||||
hoverCard: {
|
||||
fontFamily: "\"IBM Plex Sans\", Inter, system-ui, sans-serif",
|
||||
titleWeight: 700,
|
||||
titleSize: 13,
|
||||
metaWeight: 500,
|
||||
metaSize: 10,
|
||||
paddingX: 10,
|
||||
paddingY: 7,
|
||||
radius: 12,
|
||||
offsetX: 16,
|
||||
offsetY: 16,
|
||||
metaGap: 5,
|
||||
background: "rgba(8, 14, 24, 0.94)",
|
||||
borderColor: "rgba(154, 181, 212, 0.18)",
|
||||
borderAlpha: 0.32,
|
||||
textColor: "#F6FBFF",
|
||||
metaColor: "rgba(184, 214, 255, 0.58)",
|
||||
shadowColor: "rgba(0, 0, 0, 0.62)",
|
||||
shadowAlpha: 0.34,
|
||||
shadowBlur: 15,
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
backgroundScale: 0.52,
|
||||
mutedAlpha: 0.08,
|
||||
strokeHierarchy: {
|
||||
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
|
||||
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
|
||||
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
||||
},
|
||||
states: {
|
||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.18, minSize: 12.5, forceLabel: true, zIndex: 4, borderBoost: 0.22 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.06, minSize: 10.5, forceLabel: true, zIndex: 3, borderBoost: 0.2 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.84, minSize: 4.8, forceLabel: true, zIndex: 2, borderBoost: -0.08 },
|
||||
path: { color: "path", sizeMultiplier: 1.01, minSize: 6.2, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
},
|
||||
variants: {
|
||||
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
|
||||
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "structure" },
|
||||
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "structure" },
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" },
|
||||
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
||||
},
|
||||
selectedRing: {
|
||||
color: "#E7C57C",
|
||||
width: 1.9,
|
||||
nativeSize: 2.2,
|
||||
glowAlpha: 0.2,
|
||||
visibleFrom: "overview",
|
||||
},
|
||||
badges: {
|
||||
inferred: { color: "#C98658", label: "I" },
|
||||
temporal: { color: "#52CDEF", label: "T" },
|
||||
provenance: { color: "#A289D0", label: "P" },
|
||||
},
|
||||
badge: {
|
||||
radius: 7,
|
||||
offset: 3,
|
||||
fontSize: 8,
|
||||
textColor: "#08111d",
|
||||
background: "rgba(8, 17, 29, 0.84)",
|
||||
stroke: "rgba(255,255,255,0.14)",
|
||||
glowAlpha: 0.24,
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
states: {
|
||||
default: { color: "structure", sizeMultiplier: 0.74, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 0.72, minSize: 0.18, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 0.92, minSize: 0.5, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.5, minSize: 1.8, zIndex: 4, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
||||
muted: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
||||
},
|
||||
variants: {
|
||||
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
|
||||
directional: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0, sizeMultiplier: 1.04, glowAlpha: 0.08 },
|
||||
bidirectionalCurve: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0.18, sizeMultiplier: 1.08, glowAlpha: 0.1 },
|
||||
parallelCurve: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0.24, sizeMultiplier: 1.1, glowAlpha: 0.12 },
|
||||
pathSignal: { baseType: "arrow", arrowPolicy: "always", curveStrength: 0.16, sizeMultiplier: 1.18, glowAlpha: 0.2 },
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
hoverGlowAlpha: 0.18,
|
||||
pathGlowAlpha: 0.16,
|
||||
glowRadiusMultiplier: 4.8,
|
||||
minGlowRadius: 16,
|
||||
pulseRadius: 11,
|
||||
curveLineWidth: 1.7,
|
||||
curveGlowWidth: 6,
|
||||
badgeGlowRadius: 14,
|
||||
},
|
||||
focus: {
|
||||
maxNeighbors: 16,
|
||||
ringCapacity: 6,
|
||||
ringGap: 250,
|
||||
primaryLabels: 6,
|
||||
},
|
||||
motion: {
|
||||
cameraMs: 380,
|
||||
},
|
||||
effects: {
|
||||
pathPulse: {
|
||||
minZoomTier: "structure",
|
||||
maxSegments: 18,
|
||||
speed: 0.22,
|
||||
radius: 11,
|
||||
glowAlpha: 0.92,
|
||||
},
|
||||
pathFlow: {
|
||||
minZoomTier: "structure",
|
||||
maxSegments: 14,
|
||||
speed: 0.36,
|
||||
spacing: 0.26,
|
||||
opacity: 0.92,
|
||||
radius: 3.8,
|
||||
},
|
||||
lens: {
|
||||
minZoomTier: "structure",
|
||||
radius: 136,
|
||||
feather: 78,
|
||||
glowAlpha: 0.18,
|
||||
edgeAlpha: 0.42,
|
||||
edgeLineWidth: 1.8,
|
||||
},
|
||||
temporalEmphasis: {
|
||||
minZoomTier: "structure",
|
||||
maxHighlights: 48,
|
||||
radiusMultiplier: 4.2,
|
||||
glowAlpha: 0.12,
|
||||
},
|
||||
semanticRegions: {
|
||||
minZoomTier: "overview",
|
||||
maxRegions: 3,
|
||||
minVisibleSamples: 14,
|
||||
fogResolutionScale: 0.22,
|
||||
splatRadius: 13,
|
||||
blurPasses: 2,
|
||||
densityThreshold: 0.16,
|
||||
contourThreshold: 0.28,
|
||||
minMaskPixels: 170,
|
||||
minOccupancyRatio: 0.0022,
|
||||
dominantMassRatio: 0.62,
|
||||
outerContourMinMaskPixels: 240,
|
||||
fogAlpha: 0.11,
|
||||
innerContourAlpha: 0.16,
|
||||
outerContourAlpha: 0.045,
|
||||
},
|
||||
contours: {
|
||||
minZoomTier: "overview",
|
||||
maxContours: 3,
|
||||
baseRadius: 88,
|
||||
glowAlpha: 0.055,
|
||||
},
|
||||
legend: {
|
||||
maxGroups: 8,
|
||||
},
|
||||
diagnostics: {
|
||||
enabledInDev: import.meta.env.DEV,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ZOOM_TIER_ORDER: GraphZoomTier[] = ["overview", "structure", "inspection"];
|
||||
|
||||
export function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function clamp(min: number, value: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function withAlpha(color: string | undefined, alpha: number): string {
|
||||
if (!color) {
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
|
||||
if (normalized.length === 6) {
|
||||
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (color.startsWith("rgba(")) {
|
||||
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
}
|
||||
|
||||
if (color.startsWith("rgb(")) {
|
||||
return color.replace("rgb(", "rgba(").replace(")", `, ${alpha})`);
|
||||
}
|
||||
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
|
||||
export function darkenHex(hexColor: string, amount: number): string {
|
||||
if (!hexColor.startsWith("#")) {
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
const hex = hexColor.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
|
||||
if (normalized.length !== 6) {
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
const clampChannel = (value: number) => clamp(0, value, 255);
|
||||
const red = clampChannel(Number.parseInt(normalized.slice(0, 2), 16) - amount);
|
||||
const green = clampChannel(Number.parseInt(normalized.slice(2, 4), 16) - amount);
|
||||
const blue = clampChannel(Number.parseInt(normalized.slice(4, 6), 16) - amount);
|
||||
|
||||
return `#${[red, green, blue].map((value) => value.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
export function blendHex(baseColor: string, tintColor: string, amount: number): string {
|
||||
if (!baseColor.startsWith("#") || !tintColor.startsWith("#")) {
|
||||
return tintColor || baseColor;
|
||||
}
|
||||
|
||||
const normalize = (value: string) => {
|
||||
const hex = value.slice(1);
|
||||
return hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
};
|
||||
|
||||
const base = normalize(baseColor);
|
||||
const tint = normalize(tintColor);
|
||||
if (base.length !== 6 || tint.length !== 6) {
|
||||
return tintColor || baseColor;
|
||||
}
|
||||
|
||||
const mix = clamp(0, amount, 1);
|
||||
const mixChannel = (left: number, right: number) => Math.round(left + (right - left) * mix);
|
||||
const channels = [0, 2, 4].map((offset) => {
|
||||
const left = Number.parseInt(base.slice(offset, offset + 2), 16);
|
||||
const right = Number.parseInt(tint.slice(offset, offset + 2), 16);
|
||||
return mixChannel(left, right).toString(16).padStart(2, "0");
|
||||
});
|
||||
|
||||
return `#${channels.join("")}`;
|
||||
}
|
||||
|
||||
export function getZoomTier(ratio: number): GraphZoomTier {
|
||||
if (ratio <= GRAPH_THEME.zoomTiers.inspection.maxRatio) {
|
||||
return "inspection";
|
||||
}
|
||||
if (ratio <= GRAPH_THEME.zoomTiers.structure.maxRatio) {
|
||||
return "structure";
|
||||
}
|
||||
return "overview";
|
||||
}
|
||||
|
||||
export function zoomTierAtLeast(current: GraphZoomTier, minimum: GraphZoomTier): boolean {
|
||||
return ZOOM_TIER_ORDER.indexOf(current) >= ZOOM_TIER_ORDER.indexOf(minimum);
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type {
|
||||
GraphEffectAvailability,
|
||||
GraphEffectToggle,
|
||||
} from "../types";
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const EFFECTS_PANEL_ID = "effects-panel";
|
||||
|
||||
type EffectRowConfig = {
|
||||
key: GraphEffectToggle;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const EFFECT_ROWS: EffectRowConfig[] = [
|
||||
{
|
||||
key: "pathPulseEnabled",
|
||||
label: "Path Pulse",
|
||||
description: "Animated pulse on the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "pathFlowEnabled",
|
||||
label: "Path Flow",
|
||||
description: "Directional flow accents along the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "lensEnabled",
|
||||
label: "Neighborhood Lens",
|
||||
description: "Local emphasis around the hovered or selected node.",
|
||||
},
|
||||
{
|
||||
key: "legendEnabled",
|
||||
label: "Semantic Legend",
|
||||
description: "Compact semantic group legend for graph orientation.",
|
||||
},
|
||||
];
|
||||
|
||||
function renderAvailabilityText(availability: GraphEffectAvailability) {
|
||||
if (availability.available) {
|
||||
if (typeof availability.visibleSegments === "number" && typeof availability.segmentCap === "number") {
|
||||
return `${availability.reason} · ${availability.visibleSegments}/${availability.segmentCap} segments`;
|
||||
}
|
||||
return availability.reason;
|
||||
}
|
||||
|
||||
return availability.detail ? `${availability.reason} · ${availability.detail}` : availability.reason;
|
||||
}
|
||||
|
||||
function collectLegendItems(context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0]) {
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, context.theme.effects.legend.maxGroups);
|
||||
}
|
||||
|
||||
function EffectToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
availability,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
availability: GraphEffectAvailability;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{label}</div>
|
||||
<div style={rowDescriptionStyle}>{description}</div>
|
||||
<div style={rowMetaStyle}>{renderAvailabilityText(availability)}</div>
|
||||
</div>
|
||||
<button type="button" onClick={onToggle} style={checked ? toggleButtonActiveStyle : toggleButtonStyle}>
|
||||
{checked ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const explorationEffectsPlugin: GraphPlugin = {
|
||||
id: "exploration-effects",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "effects-toggle",
|
||||
label: "Effects",
|
||||
title: "Open exploration effects controls",
|
||||
active: context.isPanelOpen(EFFECTS_PANEL_ID),
|
||||
order: 18,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: EFFECTS_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(EFFECTS_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectsState = context.getEffectsState();
|
||||
const diagnosticsSnapshot = context.getDiagnosticsSnapshot();
|
||||
const availability = diagnosticsSnapshot?.effectAvailability;
|
||||
const legendItems = effectsState.legendEnabled ? collectLegendItems(context) : [];
|
||||
|
||||
return {
|
||||
id: EFFECTS_PANEL_ID,
|
||||
title: "Effects",
|
||||
placement: "bottom",
|
||||
order: 8,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 420,
|
||||
preferredHeight: 320,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Exploration effects</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Path and focus</div>
|
||||
{EFFECT_ROWS.map((row) => (
|
||||
<EffectToggleRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
checked={effectsState[row.key]}
|
||||
availability={
|
||||
availability?.[
|
||||
row.key === "pathPulseEnabled"
|
||||
? "pathPulse"
|
||||
: row.key === "pathFlowEnabled"
|
||||
? "pathFlow"
|
||||
: row.key === "lensEnabled"
|
||||
? "lens"
|
||||
: "legend"
|
||||
] ?? {
|
||||
enabled: effectsState[row.key],
|
||||
available: false,
|
||||
reason: "Waiting for graph runtime",
|
||||
}
|
||||
}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{effectsState.legendEnabled ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Semantic legend</div>
|
||||
{legendItems.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{legendItems.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
background: item.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${item.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{item.group}</div>
|
||||
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Legend data will populate when graph metadata is available.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Diagnostics</div>
|
||||
<EffectToggleRow
|
||||
label="Dev Diagnostics"
|
||||
description="Inspect plugin, interaction, and effect gating state."
|
||||
checked={effectsState.diagnosticsEnabled}
|
||||
availability={
|
||||
availability?.diagnostics ?? {
|
||||
enabled: effectsState.diagnosticsEnabled,
|
||||
available: false,
|
||||
reason: "Waiting for graph runtime",
|
||||
}
|
||||
}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: "diagnosticsEnabled" })}
|
||||
/>
|
||||
{effectsState.diagnosticsEnabled && diagnosticsSnapshot ? (
|
||||
<details style={detailsStyle}>
|
||||
<summary style={summaryStyle}>Runtime snapshot</summary>
|
||||
<pre style={diagnosticsPreStyle}>
|
||||
{JSON.stringify(diagnosticsSnapshot, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#dce9f8",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const toggleRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "8px 0",
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const rowDescriptionStyle: CSSProperties = {
|
||||
color: "#a1b7cf",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#7fc6ff",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const toggleButtonStyle: CSSProperties = {
|
||||
minWidth: 52,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
color: "#cfe0f4",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const toggleButtonActiveStyle: CSSProperties = {
|
||||
...toggleButtonStyle,
|
||||
background: "rgba(31, 111, 235, 0.24)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.28)",
|
||||
color: "#eef6ff",
|
||||
};
|
||||
|
||||
const legendRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const legendSwatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const detailsStyle: CSSProperties = {
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
background: "rgba(0,0,0,0.14)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
padding: "10px 12px",
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const diagnosticsPreStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
padding: "0 12px 12px",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectAvailability,
|
||||
GraphEffectToggle,
|
||||
} from "../types";
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const EFFECTS_PANEL_ID = "effects-panel";
|
||||
|
||||
type EffectRowConfig = {
|
||||
key: GraphEffectToggle;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const SCENE_EFFECT_ROWS: EffectRowConfig[] = [
|
||||
{
|
||||
key: "pathPulseEnabled",
|
||||
label: "Path Pulse",
|
||||
description: "Animated pulse on the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "pathFlowEnabled",
|
||||
label: "Path Flow",
|
||||
description: "Directional flow accents along the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "lensEnabled",
|
||||
label: "Neighborhood Lens",
|
||||
description: "Local emphasis around the hovered or selected node.",
|
||||
},
|
||||
{
|
||||
key: "temporalEmphasisEnabled",
|
||||
label: "Temporal Emphasis",
|
||||
description: "Subtle glow around temporally relevant nodes in the active time window.",
|
||||
},
|
||||
{
|
||||
key: "semanticRegionsEnabled",
|
||||
label: "Semantic Regions",
|
||||
description: "Quiet semantic hulls around the strongest visible topic clusters.",
|
||||
},
|
||||
{
|
||||
key: "contoursEnabled",
|
||||
label: "Contours",
|
||||
description: "Low-contrast density halos around the strongest visible anchors.",
|
||||
},
|
||||
{
|
||||
key: "legendEnabled",
|
||||
label: "Regions Summary",
|
||||
description: "Keep the regions and signals summary visible in the Effects panel.",
|
||||
},
|
||||
];
|
||||
|
||||
const INTELLIGENCE_EFFECT_ROWS: EffectRowConfig[] = [
|
||||
{
|
||||
key: "pathfindingEnabled",
|
||||
label: "Directed Pathfinding",
|
||||
description: "Compare the traced path against a strict local directed shortest path.",
|
||||
},
|
||||
{
|
||||
key: "communitiesEnabled",
|
||||
label: "Community Regions",
|
||||
description: "Detect stable Louvain communities for orientation and scene grouping.",
|
||||
},
|
||||
{
|
||||
key: "centralityEnabled",
|
||||
label: "Centrality Ranking",
|
||||
description: "Rank the strongest graph anchors for labels, regions, and navigation.",
|
||||
},
|
||||
];
|
||||
|
||||
const AVAILABILITY_KEYS: Record<GraphEffectToggle, keyof GraphDiagnosticsSnapshot["effectAvailability"]> = {
|
||||
pathPulseEnabled: "pathPulse",
|
||||
pathFlowEnabled: "pathFlow",
|
||||
lensEnabled: "lens",
|
||||
temporalEmphasisEnabled: "temporalEmphasis",
|
||||
semanticRegionsEnabled: "semanticRegions",
|
||||
contoursEnabled: "contours",
|
||||
pathfindingEnabled: "pathfinding",
|
||||
communitiesEnabled: "communities",
|
||||
centralityEnabled: "centrality",
|
||||
legendEnabled: "legend",
|
||||
diagnosticsEnabled: "diagnostics",
|
||||
};
|
||||
|
||||
function renderAvailabilityText(availability: GraphEffectAvailability) {
|
||||
if (availability.available) {
|
||||
if (typeof availability.visibleSegments === "number" && typeof availability.segmentCap === "number") {
|
||||
return `${availability.reason} - ${availability.visibleSegments}/${availability.segmentCap} segments`;
|
||||
}
|
||||
return availability.reason;
|
||||
}
|
||||
|
||||
return availability.detail ? `${availability.reason} - ${availability.detail}` : availability.reason;
|
||||
}
|
||||
|
||||
function collectFallbackLegendItems(context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0]) {
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, context.theme.effects.legend.maxGroups);
|
||||
}
|
||||
|
||||
function resolveAvailability(
|
||||
availabilityMap: GraphDiagnosticsSnapshot["effectAvailability"] | undefined,
|
||||
key: GraphEffectToggle,
|
||||
enabled: boolean,
|
||||
): GraphEffectAvailability {
|
||||
return availabilityMap?.[AVAILABILITY_KEYS[key]] ?? {
|
||||
enabled,
|
||||
available: false,
|
||||
reason: "Waiting for graph runtime",
|
||||
};
|
||||
}
|
||||
|
||||
function EffectToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
availability,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
availability: GraphEffectAvailability;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{label}</div>
|
||||
<div style={rowDescriptionStyle}>{description}</div>
|
||||
<div style={rowMetaStyle}>{renderAvailabilityText(availability)}</div>
|
||||
</div>
|
||||
<button type="button" onClick={onToggle} style={checked ? toggleButtonActiveStyle : toggleButtonStyle}>
|
||||
{checked ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRegionsAndSignals(
|
||||
context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0],
|
||||
analytics: GraphAnalyticsSnapshot | null,
|
||||
) {
|
||||
const fallbackLegendItems = collectFallbackLegendItems(context);
|
||||
const semanticRegions = analytics?.semanticRegions.summaries ?? [];
|
||||
const communities = analytics?.communities.summaries ?? [];
|
||||
const centrality = analytics?.centrality.topNodes ?? [];
|
||||
const directedPath = analytics?.directedPath ?? null;
|
||||
|
||||
if (!semanticRegions.length && !communities.length && !centrality.length && !fallbackLegendItems.length && !directedPath) {
|
||||
return <div style={emptyTextStyle}>Regions and intelligence summaries will populate when graph analytics are ready.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{semanticRegions.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Semantic regions</div>
|
||||
{semanticRegions.map((region) => (
|
||||
<div key={region.semanticGroup} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
background: region.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${region.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{region.semanticGroup}</div>
|
||||
<div style={rowMetaStyle}>
|
||||
{region.visibleNodeCount.toLocaleString()} visible / {region.nodeCount.toLocaleString()} total
|
||||
</div>
|
||||
</div>
|
||||
<div style={signalBadgeStyle}>{region.anchorLabel}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{communities.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Community anchors</div>
|
||||
{communities.slice(0, 3).map((community) => (
|
||||
<div key={community.communityId} style={signalRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{community.anchorLabel}</div>
|
||||
<div style={rowMetaStyle}>
|
||||
Community {community.communityId} - {community.visibleNodeCount} visible / {community.nodeCount} total
|
||||
</div>
|
||||
</div>
|
||||
<div style={signalBadgeStyle}>{community.dominantSemanticGroup}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{centrality.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Centrality leaders</div>
|
||||
{centrality.slice(0, 3).map((node) => (
|
||||
<div key={node.id} style={signalRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{node.label}</div>
|
||||
<div style={rowMetaStyle}>
|
||||
{node.semanticGroup} - score {node.score.toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={signalBadgeStyle}>deg {node.degree.toFixed(3)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{directedPath ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Directed pathfinding</div>
|
||||
<div style={signalRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{directedPath.ready ? "Local directed path ready" : "Waiting for path context"}</div>
|
||||
<div style={rowMetaStyle}>{directedPath.reason}</div>
|
||||
</div>
|
||||
{directedPath.ready ? (
|
||||
<div style={signalBadgeStyle}>
|
||||
{directedPath.length} hops{directedPath.verifiedAgainstActivePath ? " - match" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!semanticRegions.length && !communities.length && !centrality.length && fallbackLegendItems.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Fallback semantic legend</div>
|
||||
{fallbackLegendItems.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
background: item.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${item.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{item.group}</div>
|
||||
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const explorationEffectsPluginPhaseC: GraphPlugin = {
|
||||
id: "exploration-effects",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "effects-toggle",
|
||||
label: "Effects",
|
||||
title: "Open exploration effects controls",
|
||||
active: context.isPanelOpen(EFFECTS_PANEL_ID),
|
||||
order: 18,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: EFFECTS_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(EFFECTS_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectsState = context.getEffectsState();
|
||||
const diagnosticsSnapshot = context.getDiagnosticsSnapshot();
|
||||
const analyticsSnapshot = context.getAnalyticsSnapshot();
|
||||
const availability = diagnosticsSnapshot?.effectAvailability;
|
||||
const showSignalsSection =
|
||||
effectsState.legendEnabled
|
||||
|| effectsState.semanticRegionsEnabled
|
||||
|| effectsState.communitiesEnabled
|
||||
|| effectsState.centralityEnabled
|
||||
|| effectsState.pathfindingEnabled;
|
||||
|
||||
return {
|
||||
id: EFFECTS_PANEL_ID,
|
||||
title: "Effects",
|
||||
placement: "bottom",
|
||||
order: 8,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 460,
|
||||
preferredHeight: 360,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Exploration effects</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Scene effects</div>
|
||||
{SCENE_EFFECT_ROWS.map((row) => (
|
||||
<EffectToggleRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
checked={effectsState[row.key]}
|
||||
availability={resolveAvailability(availability, row.key, effectsState[row.key])}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Graph intelligence</div>
|
||||
{INTELLIGENCE_EFFECT_ROWS.map((row) => (
|
||||
<EffectToggleRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
checked={effectsState[row.key]}
|
||||
availability={resolveAvailability(availability, row.key, effectsState[row.key])}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showSignalsSection ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Regions and signals</div>
|
||||
{renderRegionsAndSignals(context, analyticsSnapshot)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Diagnostics</div>
|
||||
<EffectToggleRow
|
||||
label="Dev Diagnostics"
|
||||
description="Inspect plugin, interaction, and effect gating state."
|
||||
checked={effectsState.diagnosticsEnabled}
|
||||
availability={resolveAvailability(availability, "diagnosticsEnabled", effectsState.diagnosticsEnabled)}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: "diagnosticsEnabled" })}
|
||||
/>
|
||||
{effectsState.diagnosticsEnabled && diagnosticsSnapshot ? (
|
||||
<details style={detailsStyle}>
|
||||
<summary style={summaryStyle}>Runtime snapshot</summary>
|
||||
<pre style={diagnosticsPreStyle}>
|
||||
{JSON.stringify({ diagnostics: diagnosticsSnapshot, analytics: analyticsSnapshot }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#dce9f8",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const subsectionTitleStyle: CSSProperties = {
|
||||
color: "#9cc4ec",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const toggleRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "8px 0",
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const rowDescriptionStyle: CSSProperties = {
|
||||
color: "#a1b7cf",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#7fc6ff",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const toggleButtonStyle: CSSProperties = {
|
||||
minWidth: 52,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
color: "#cfe0f4",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const toggleButtonActiveStyle: CSSProperties = {
|
||||
...toggleButtonStyle,
|
||||
background: "rgba(31, 111, 235, 0.24)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.28)",
|
||||
color: "#eef6ff",
|
||||
};
|
||||
|
||||
const legendRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const legendSwatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const signalRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
};
|
||||
|
||||
const signalBadgeStyle: CSSProperties = {
|
||||
padding: "5px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(31, 111, 235, 0.16)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.16)",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const detailsStyle: CSSProperties = {
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
background: "rgba(0,0,0,0.14)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
padding: "10px 12px",
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const diagnosticsPreStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
padding: "0 12px 12px",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export { explorationEffectsPluginPhaseC as explorationEffectsPlugin } from "./explorationEffectsPluginPhaseC";
|
||||
export { legendPlugin } from "./legendPlugin";
|
||||
export { neighborhoodPanelPlugin } from "./neighborhoodPanelPlugin";
|
||||
export { temporalOverlayPlugin } from "./temporalOverlayPlugin";
|
||||
export type {
|
||||
GraphPlugin,
|
||||
GraphPluginActionRequest,
|
||||
GraphPluginContext,
|
||||
GraphInspectorState,
|
||||
GraphPluginId,
|
||||
GraphPluginOverlayDescriptor,
|
||||
GraphPluginPanelDescriptor,
|
||||
GraphPluginRegistryEntry,
|
||||
GraphPluginToolbarItem,
|
||||
GraphTemporalState,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const LEGEND_PANEL_ID = "legend-panel";
|
||||
const MAX_GROUPS = 8;
|
||||
|
||||
export const legendPlugin: GraphPlugin = {
|
||||
id: "legend",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "legend-toggle",
|
||||
label: "Legend",
|
||||
title: "Toggle semantic legend",
|
||||
active: context.isPanelOpen(LEGEND_PANEL_ID),
|
||||
order: 20,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: LEGEND_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(LEGEND_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
const items = [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, MAX_GROUPS);
|
||||
|
||||
return {
|
||||
id: LEGEND_PANEL_ID,
|
||||
title: "Legend",
|
||||
placement: "bottom",
|
||||
order: 10,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 320,
|
||||
preferredHeight: 220,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Semantic groups</div>
|
||||
{items.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...swatchStyle,
|
||||
background: item.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 18px ${item.color}44`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{item.group}</div>
|
||||
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Legend will populate when the graph metadata is available.</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const legendRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const swatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const NEIGHBORHOOD_PANEL_ID = "neighborhood-panel";
|
||||
const MAX_NEIGHBORS = 10;
|
||||
|
||||
function maxWeightBetween(graphRef: any, sourceId: string, targetId: string): number {
|
||||
let weight = 0;
|
||||
graphRef.forEachDirectedEdge(sourceId, targetId, (_edgeId: string, attrs: { weight?: number }) => {
|
||||
weight = Math.max(weight, Number(attrs.weight ?? 0));
|
||||
});
|
||||
return weight;
|
||||
}
|
||||
|
||||
function formatNeighborMeta(neighbor: { nodeType: string; degree: number; weight: number }) {
|
||||
const parts = [neighbor.nodeType, `degree ${neighbor.degree}`];
|
||||
if (neighbor.weight > 0) {
|
||||
parts.push(`weight ${neighbor.weight.toFixed(2)}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
id: "neighborhood-panel",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "neighborhood-toggle",
|
||||
label: "Neighbors",
|
||||
title: "Toggle neighborhood panel",
|
||||
active: context.isPanelOpen(NEIGHBORHOOD_PANEL_ID),
|
||||
order: 30,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: NEIGHBORHOOD_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(NEIGHBORHOOD_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected = context.getSelectedNodeState();
|
||||
if (!selected) {
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
title: "Neighborhood",
|
||||
placement: "bottom",
|
||||
order: 20,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 360,
|
||||
preferredHeight: 260,
|
||||
content: <div style={emptyTextStyle}>Select a node to inspect its local neighborhood.</div>,
|
||||
};
|
||||
}
|
||||
|
||||
const neighbors = context.graph
|
||||
.neighbors(selected.id)
|
||||
.map((neighborId) => {
|
||||
const attrs = context.graph.getNodeAttributes(neighborId);
|
||||
const weight = Math.max(
|
||||
maxWeightBetween(context.graph, selected.id, neighborId),
|
||||
maxWeightBetween(context.graph, neighborId, selected.id),
|
||||
);
|
||||
return {
|
||||
id: neighborId,
|
||||
label: String(attrs.label || neighborId),
|
||||
nodeType: String(attrs.nodeType || "Entity"),
|
||||
color: String(attrs.baseColor || attrs.color || context.theme.palette.semantic[0]),
|
||||
weight,
|
||||
degree: context.graph.degree(neighborId),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (right.weight !== left.weight) {
|
||||
return right.weight - left.weight;
|
||||
}
|
||||
if (right.degree !== left.degree) {
|
||||
return right.degree - left.degree;
|
||||
}
|
||||
return left.label.localeCompare(right.label);
|
||||
})
|
||||
.slice(0, MAX_NEIGHBORS);
|
||||
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
title: "Neighborhood",
|
||||
placement: "bottom",
|
||||
order: 20,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 360,
|
||||
preferredHeight: 260,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>{selected.label}</div>
|
||||
<div style={summaryStyle}>
|
||||
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
|
||||
</div>
|
||||
{neighbors.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{neighbors.map((neighbor) => (
|
||||
<button
|
||||
key={neighbor.id}
|
||||
type="button"
|
||||
onClick={() => context.dispatchAction({ type: "selectNode", nodeId: neighbor.id })}
|
||||
style={neighborButtonStyle}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...swatchStyle,
|
||||
background: neighbor.color,
|
||||
boxShadow: `0 0 16px ${neighbor.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1, textAlign: "left" }}>
|
||||
<div style={rowTitleStyle}>{neighbor.label}</div>
|
||||
<div style={rowMetaStyle}>{formatNeighborMeta(neighbor)}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>No direct neighbors are available for this node.</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const neighborButtonStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
borderRadius: 12,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const swatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const TEMPORAL_PANEL_ID = "temporal-panel";
|
||||
|
||||
function formatTemporalLabel(value: Date | null) {
|
||||
if (!value) {
|
||||
return "No time selected";
|
||||
}
|
||||
return `${value.getFullYear()}/${String(value.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export const temporalOverlayPlugin: GraphPlugin = {
|
||||
id: "temporal-overlay",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "temporal-toggle",
|
||||
label: "Temporal",
|
||||
title: "Toggle temporal context panel",
|
||||
active: context.isPanelOpen(TEMPORAL_PANEL_ID),
|
||||
order: 40,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: TEMPORAL_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderOverlay: (context) => {
|
||||
const temporal = context.getTemporalState();
|
||||
if (!temporal?.currentTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = formatTemporalLabel(temporal.currentTime);
|
||||
return {
|
||||
id: "temporal-overlay-chip",
|
||||
layer: 1,
|
||||
order: 10,
|
||||
element: (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 140,
|
||||
bottom: 26,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(127, 208, 255, 0.18)",
|
||||
background: "linear-gradient(135deg, rgba(6, 15, 27, 0.88), rgba(11, 22, 39, 0.76))",
|
||||
boxShadow: "0 12px 30px rgba(0, 0, 0, 0.28)",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#7fc6ff", fontWeight: 700 }}>Temporal</span>
|
||||
<span>{label}</span>
|
||||
{typeof temporal.activeNodeCount === "number" ? (
|
||||
<span style={{ color: "#8ea4be" }}>{temporal.activeNodeCount.toLocaleString()} active</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(TEMPORAL_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const temporal = context.getTemporalState();
|
||||
return {
|
||||
id: TEMPORAL_PANEL_ID,
|
||||
title: "Temporal Context",
|
||||
placement: "bottom",
|
||||
order: 30,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 320,
|
||||
preferredHeight: 220,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Current scrubber state</div>
|
||||
<div style={detailRowStyle}>
|
||||
<span style={detailLabelStyle}>Current</span>
|
||||
<span style={detailValueStyle}>{formatTemporalLabel(temporal?.currentTime ?? null)}</span>
|
||||
</div>
|
||||
<div style={detailRowStyle}>
|
||||
<span style={detailLabelStyle}>Bounds</span>
|
||||
<span style={detailValueStyle}>
|
||||
{(temporal?.minDate ?? "1970")} → {(temporal?.maxDate ?? "2030")}
|
||||
</span>
|
||||
</div>
|
||||
<div style={detailRowStyle}>
|
||||
<span style={detailLabelStyle}>Active nodes</span>
|
||||
<span style={detailValueStyle}>
|
||||
{typeof temporal?.activeNodeCount === "number" ? temporal.activeNodeCount.toLocaleString() : "All"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const detailRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const detailLabelStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const detailValueStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type Graph from "graphology";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../../store/graphStore";
|
||||
import type { GraphTheme } from "../graphTheme";
|
||||
import type { GraphSceneRuntime } from "../scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphEffectToggle,
|
||||
GraphInteractionState,
|
||||
GraphLoadSummary,
|
||||
GraphSelectedNodeState,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "../types";
|
||||
|
||||
export type { GraphTemporalState } from "../types";
|
||||
|
||||
export type GraphPluginId = string;
|
||||
export type GraphPluginPanelPlacement = "side" | "bottom";
|
||||
|
||||
export interface GraphInspectorState {
|
||||
selectedNodeId: string | null;
|
||||
ownsSelectionDetails: boolean;
|
||||
}
|
||||
|
||||
export type GraphPluginActionRequest =
|
||||
| { type: "fitView" }
|
||||
| { type: "focusNode"; nodeId: string }
|
||||
| { type: "selectNode"; nodeId: string }
|
||||
| { type: "setViewMode"; viewMode: GraphViewMode }
|
||||
| { type: "toggleEffect"; effect: GraphEffectToggle }
|
||||
| { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean }
|
||||
| { type: "togglePanel"; panelId: string }
|
||||
| { type: "openPanel"; panelId: string }
|
||||
| { type: "closePanel"; panelId: string };
|
||||
|
||||
export interface GraphPluginToolbarItem {
|
||||
id: string;
|
||||
label: string;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
order?: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface GraphPluginPanelDescriptor {
|
||||
id: string;
|
||||
title: string;
|
||||
placement: GraphPluginPanelPlacement;
|
||||
order?: number;
|
||||
defaultOpen?: boolean;
|
||||
preferredHeight?: number;
|
||||
preferredWidth?: number;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export interface GraphPluginOverlayDescriptor {
|
||||
id: string;
|
||||
layer?: number;
|
||||
order?: number;
|
||||
element: ReactNode;
|
||||
}
|
||||
|
||||
export interface GraphPluginContext {
|
||||
readonly scene: GraphSceneRuntime | null;
|
||||
readonly graph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
readonly displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
readonly theme: GraphTheme;
|
||||
getInteractionState: () => GraphInteractionState;
|
||||
getSelectedNodeState: () => GraphSelectedNodeState | null;
|
||||
getInspectorState: () => GraphInspectorState;
|
||||
getGraphSummary: () => GraphLoadSummary | null;
|
||||
getTemporalState: () => GraphTemporalState | null;
|
||||
getEffectsState: () => GraphEffectsState;
|
||||
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
|
||||
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
|
||||
isPanelOpen: (panelId: string) => boolean;
|
||||
dispatchAction: (action: GraphPluginActionRequest) => void;
|
||||
}
|
||||
|
||||
export interface GraphPlugin {
|
||||
id: GraphPluginId;
|
||||
mount: (context: GraphPluginContext) => void;
|
||||
unmount: (context: GraphPluginContext) => void;
|
||||
onStateChange: (context: GraphPluginContext, interactionState: GraphInteractionState) => void;
|
||||
renderOverlay?: (
|
||||
context: GraphPluginContext,
|
||||
) => GraphPluginOverlayDescriptor | GraphPluginOverlayDescriptor[] | null;
|
||||
renderPanel?: (
|
||||
context: GraphPluginContext,
|
||||
) => GraphPluginPanelDescriptor | GraphPluginPanelDescriptor[] | null;
|
||||
toolbarItems?: (context: GraphPluginContext) => GraphPluginToolbarItem[];
|
||||
}
|
||||
|
||||
export interface GraphPluginRegistryEntry {
|
||||
plugin: GraphPlugin;
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from "react";
|
||||
import type Graph from "graphology";
|
||||
|
||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
GraphLayoutSource,
|
||||
GraphLayoutStatus,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
export type GraphSceneGraph = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
export type GraphSceneRenderer = "sigma";
|
||||
|
||||
export interface GraphSceneRuntime {
|
||||
renderer: GraphSceneRenderer;
|
||||
scene: unknown;
|
||||
graph: GraphSceneGraph;
|
||||
displayGraph: GraphSceneGraph;
|
||||
requestRender: () => void;
|
||||
getCameraState: () => GraphCameraState | null;
|
||||
}
|
||||
|
||||
export interface GraphSceneEventMap {
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
onEdgeSelect?: (edgeId: string) => void;
|
||||
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
|
||||
onCameraStateChange?: (cameraState: GraphCameraState) => void;
|
||||
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
|
||||
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
|
||||
onRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
|
||||
}
|
||||
|
||||
export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
selectedNodeId: string;
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
effectsState: GraphEffectsState;
|
||||
temporalState?: GraphTemporalState | null;
|
||||
isLayoutRunning: boolean;
|
||||
onLayoutRunningChange?: (running: boolean) => void;
|
||||
layoutSource?: GraphLayoutSource;
|
||||
onLayoutStatusChange?: (status: GraphLayoutStatus) => void;
|
||||
viewMode: GraphViewMode;
|
||||
className?: string;
|
||||
showFitViewButton?: boolean;
|
||||
pluginOverlays?: ReactNode[];
|
||||
}
|
||||
|
||||
export interface GraphSceneHandle {
|
||||
fitView: () => void;
|
||||
focusNode: (nodeId: string) => void;
|
||||
getRuntime: () => GraphSceneRuntime | null;
|
||||
setLayoutRunning?: (running: boolean) => void;
|
||||
}
|
||||
|
||||
export type GraphSceneAdapter = ForwardRefExoticComponent<
|
||||
GraphSceneProps & RefAttributes<GraphSceneHandle>
|
||||
>;
|
||||
@@ -0,0 +1,336 @@
|
||||
import EdgeCurveProgram, { EdgeCurvedArrowProgram } from "@sigma/edge-curve";
|
||||
import { NodeProgram, type ProgramInfo } from "sigma/rendering";
|
||||
import { DEFAULT_EDGE_PROGRAM_CLASSES, DEFAULT_NODE_PROGRAM_CLASSES } from "sigma/settings";
|
||||
import type { NodeDisplayData, RenderParams } from "sigma/types";
|
||||
import { floatColor } from "sigma/utils";
|
||||
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
||||
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
|
||||
type SemanticaNodeDrawData = {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
label: string;
|
||||
color: string;
|
||||
shellColor?: string;
|
||||
coreScale?: number;
|
||||
borderColor?: string;
|
||||
ringColor?: string;
|
||||
ringSize?: number;
|
||||
nodeType?: string;
|
||||
};
|
||||
|
||||
const MINERAL_DISC_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
|
||||
const MINERAL_DISC_FRAGMENT_SHADER = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
float discMetric(vec2 point) {
|
||||
return length(point);
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 unit = v_diffVector / max(v_radius, 0.0001);
|
||||
float metric = discMetric(unit);
|
||||
float aa = (2.4 * u_correctionRatio) / max(v_radius, 1.0);
|
||||
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, metric);
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
if (alpha <= 0.0) {
|
||||
gl_FragColor = transparent;
|
||||
} else {
|
||||
gl_FragColor = v_color;
|
||||
gl_FragColor.a *= bias;
|
||||
}
|
||||
#else
|
||||
if (alpha <= 0.0) {
|
||||
gl_FragColor = transparent;
|
||||
return;
|
||||
}
|
||||
|
||||
float ringNorm = clamp(v_ringSize / max(v_radius, 1.0), 0.0, 0.45);
|
||||
float ringStart = max(0.0, 1.0 - ringNorm);
|
||||
float coreEdge = clamp(v_coreScale, 0.06, 0.78);
|
||||
float coreBlend = 1.0 - smoothstep(max(coreEdge - 0.14, 0.0), coreEdge, metric);
|
||||
float bodyLight = 1.0 - smoothstep(0.0, 0.82, metric);
|
||||
vec4 color = mix(v_shellColor, v_coreColor, coreBlend);
|
||||
color.rgb += vec3(0.022) * pow(bodyLight, 1.45);
|
||||
|
||||
if (ringNorm > 0.0 && metric >= ringStart) {
|
||||
color = v_ringColor;
|
||||
}
|
||||
color.a *= alpha;
|
||||
gl_FragColor = color;
|
||||
#endif
|
||||
}
|
||||
`;
|
||||
|
||||
const MINERAL_DISC_VERTEX_SHADER = /* glsl */ `
|
||||
attribute vec4 a_id;
|
||||
attribute vec2 a_position;
|
||||
attribute float a_size;
|
||||
attribute float a_angle;
|
||||
attribute vec4 a_coreColor;
|
||||
attribute vec4 a_shellColor;
|
||||
attribute vec4 a_ringColor;
|
||||
attribute float a_ringSize;
|
||||
attribute float a_coreScale;
|
||||
|
||||
uniform mat3 u_matrix;
|
||||
uniform float u_sizeRatio;
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
|
||||
void main() {
|
||||
float size = a_size * u_correctionRatio / u_sizeRatio * 4.0;
|
||||
vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle));
|
||||
vec2 position = a_position + diffVector;
|
||||
|
||||
gl_Position = vec4(
|
||||
(u_matrix * vec3(position, 1)).xy,
|
||||
0,
|
||||
1
|
||||
);
|
||||
|
||||
v_diffVector = diffVector;
|
||||
v_radius = size / 2.0;
|
||||
v_ringSize = a_ringSize;
|
||||
v_coreScale = a_coreScale;
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
v_color = a_id;
|
||||
#else
|
||||
v_coreColor = a_coreColor;
|
||||
v_shellColor = a_shellColor;
|
||||
v_ringColor = a_ringColor;
|
||||
#endif
|
||||
|
||||
v_color.a *= bias;
|
||||
}
|
||||
`;
|
||||
|
||||
class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[number]> {
|
||||
static readonly ANGLE_1 = 0;
|
||||
static readonly ANGLE_2 = (2 * Math.PI) / 3;
|
||||
static readonly ANGLE_3 = (4 * Math.PI) / 3;
|
||||
|
||||
drawLabel = drawSemanticaNodeLabel;
|
||||
|
||||
drawHover = drawSemanticaNodeHover;
|
||||
|
||||
getDefinition() {
|
||||
return {
|
||||
VERTICES: 3,
|
||||
VERTEX_SHADER_SOURCE: MINERAL_DISC_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: MINERAL_DISC_FRAGMENT_SHADER,
|
||||
METHOD: WebGLRenderingContext.TRIANGLES,
|
||||
UNIFORMS: MINERAL_DISC_UNIFORMS,
|
||||
ATTRIBUTES: [
|
||||
{ name: "a_position", size: 2, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_size", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_coreColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_shellColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringSize", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_coreScale", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_id", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
],
|
||||
CONSTANT_ATTRIBUTES: [
|
||||
{ name: "a_angle", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
],
|
||||
CONSTANT_DATA: [
|
||||
[MineralDiscNodeProgram.ANGLE_1],
|
||||
[MineralDiscNodeProgram.ANGLE_2],
|
||||
[MineralDiscNodeProgram.ANGLE_3],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
processVisibleItem(nodeIndex: number, startIndex: number, data: NodeDisplayData & SemanticaNodeDrawData): void {
|
||||
const array = this.array;
|
||||
const ringColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
|
||||
|
||||
array[startIndex++] = data.x;
|
||||
array[startIndex++] = data.y;
|
||||
array[startIndex++] = data.size;
|
||||
array[startIndex++] = floatColor(data.color || GRAPH_THEME.palette.overview.nodeCore);
|
||||
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, GRAPH_THEME.palette.overview.nodeShellAlpha));
|
||||
array[startIndex++] = floatColor(ringColor);
|
||||
array[startIndex++] = data.ringSize || 0;
|
||||
array[startIndex++] = data.coreScale ?? 0.22;
|
||||
array[startIndex++] = nodeIndex;
|
||||
}
|
||||
|
||||
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof MINERAL_DISC_UNIFORMS)[number]>): void {
|
||||
gl.uniform1f(uniformLocations.u_correctionRatio, params.correctionRatio);
|
||||
gl.uniform1f(uniformLocations.u_sizeRatio, params.sizeRatio);
|
||||
gl.uniformMatrix3fv(uniformLocations.u_matrix, false, params.matrix);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAccentBorderColor(
|
||||
ringSize: number | undefined,
|
||||
ringColor: string | undefined,
|
||||
borderColor: string | undefined,
|
||||
fallbackColor: string,
|
||||
) {
|
||||
if (typeof ringSize === "number" && ringSize > 0 && ringColor) {
|
||||
return ringColor;
|
||||
}
|
||||
|
||||
return borderColor || fallbackColor;
|
||||
}
|
||||
|
||||
function drawRoundedRect(
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
) {
|
||||
const clampedRadius = Math.max(0, Math.min(radius, Math.min(width, height) / 2));
|
||||
context.beginPath();
|
||||
context.moveTo(x + clampedRadius, y);
|
||||
context.lineTo(x + width - clampedRadius, y);
|
||||
context.quadraticCurveTo(x + width, y, x + width, y + clampedRadius);
|
||||
context.lineTo(x + width, y + height - clampedRadius);
|
||||
context.quadraticCurveTo(x + width, y + height, x + width - clampedRadius, y + height);
|
||||
context.lineTo(x + clampedRadius, y + height);
|
||||
context.quadraticCurveTo(x, y + height, x, y + height - clampedRadius);
|
||||
context.lineTo(x, y + clampedRadius);
|
||||
context.quadraticCurveTo(x, y, x + clampedRadius, y);
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
export const drawSemanticaNodeLabel: NodeLabelDrawingFunction = (context, rawData) => {
|
||||
const data = rawData as typeof rawData & SemanticaNodeDrawData;
|
||||
if (!data.label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chipTheme = GRAPH_THEME.labels.chip;
|
||||
const fontSize = Math.max(
|
||||
chipTheme.fontSize,
|
||||
Math.min(chipTheme.maxFontSize, data.size * chipTheme.sizeScale),
|
||||
);
|
||||
const font = `${chipTheme.fontWeight} ${fontSize}px ${chipTheme.fontFamily}`;
|
||||
const paddingX = chipTheme.paddingX;
|
||||
const paddingY = chipTheme.paddingY;
|
||||
const borderColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, chipTheme.borderColor);
|
||||
|
||||
context.save();
|
||||
context.font = font;
|
||||
context.textBaseline = "middle";
|
||||
const metrics = context.measureText(data.label);
|
||||
const width = metrics.width + paddingX * 2;
|
||||
const height = fontSize + paddingY * 2;
|
||||
const x = data.x + Math.max(data.size * 0.7, chipTheme.offsetX);
|
||||
const y = data.y - Math.max(data.size * 0.9, chipTheme.offsetY) - height;
|
||||
|
||||
context.shadowColor = withAlpha(chipTheme.shadowColor, chipTheme.shadowAlpha);
|
||||
context.shadowBlur = chipTheme.shadowBlur;
|
||||
context.fillStyle = chipTheme.background;
|
||||
drawRoundedRect(context, x, y, width, height, chipTheme.radius);
|
||||
context.fill();
|
||||
|
||||
context.shadowBlur = 0;
|
||||
context.strokeStyle = withAlpha(borderColor, chipTheme.borderAlpha);
|
||||
context.lineWidth = 1;
|
||||
drawRoundedRect(context, x, y, width, height, chipTheme.radius);
|
||||
context.stroke();
|
||||
|
||||
context.fillStyle = chipTheme.textColor;
|
||||
context.fillText(data.label, x + paddingX, y + height / 2);
|
||||
context.restore();
|
||||
};
|
||||
|
||||
export const drawSemanticaNodeHover: NodeHoverDrawingFunction = (context, rawData) => {
|
||||
const data = rawData as typeof rawData & SemanticaNodeDrawData;
|
||||
if (!data.label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoverTheme = GRAPH_THEME.labels.hoverCard;
|
||||
const borderColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, hoverTheme.borderColor);
|
||||
const metaLabel = (typeof data.nodeType === "string" && data.nodeType.trim().length > 0)
|
||||
? data.nodeType.replaceAll("_", " ").toUpperCase()
|
||||
: "NODE";
|
||||
|
||||
context.save();
|
||||
context.textBaseline = "top";
|
||||
|
||||
const titleFont = `${hoverTheme.titleWeight} ${hoverTheme.titleSize}px ${hoverTheme.fontFamily}`;
|
||||
const metaFont = `${hoverTheme.metaWeight} ${hoverTheme.metaSize}px ${hoverTheme.fontFamily}`;
|
||||
context.font = titleFont;
|
||||
const titleWidth = context.measureText(data.label).width;
|
||||
context.font = metaFont;
|
||||
const metaWidth = context.measureText(metaLabel).width;
|
||||
|
||||
const width = Math.max(titleWidth, metaWidth) + hoverTheme.paddingX * 2;
|
||||
const height = hoverTheme.paddingY * 2 + hoverTheme.titleSize + hoverTheme.metaGap + hoverTheme.metaSize;
|
||||
const x = data.x + Math.max(data.size * 0.9, hoverTheme.offsetX);
|
||||
const y = data.y - Math.max(data.size * 1.1, hoverTheme.offsetY) - height;
|
||||
|
||||
context.shadowColor = withAlpha(hoverTheme.shadowColor, hoverTheme.shadowAlpha);
|
||||
context.shadowBlur = hoverTheme.shadowBlur;
|
||||
context.fillStyle = hoverTheme.background;
|
||||
drawRoundedRect(context, x, y, width, height, hoverTheme.radius);
|
||||
context.fill();
|
||||
|
||||
context.shadowBlur = 0;
|
||||
context.strokeStyle = withAlpha(borderColor, hoverTheme.borderAlpha);
|
||||
context.lineWidth = 1.2;
|
||||
drawRoundedRect(context, x, y, width, height, hoverTheme.radius);
|
||||
context.stroke();
|
||||
|
||||
context.fillStyle = hoverTheme.textColor;
|
||||
context.font = titleFont;
|
||||
context.fillText(data.label, x + hoverTheme.paddingX, y + hoverTheme.paddingY);
|
||||
|
||||
context.fillStyle = hoverTheme.metaColor;
|
||||
context.font = metaFont;
|
||||
context.fillText(
|
||||
metaLabel,
|
||||
x + hoverTheme.paddingX,
|
||||
y + hoverTheme.paddingY + hoverTheme.titleSize + hoverTheme.metaGap,
|
||||
);
|
||||
|
||||
context.restore();
|
||||
};
|
||||
|
||||
export const SEMANTICA_NODE_PROGRAM_CLASSES = {
|
||||
...DEFAULT_NODE_PROGRAM_CLASSES,
|
||||
circle: MineralDiscNodeProgram,
|
||||
};
|
||||
|
||||
export const SEMANTICA_EDGE_PROGRAM_CLASSES = {
|
||||
...DEFAULT_EDGE_PROGRAM_CLASSES,
|
||||
curve: EdgeCurveProgram,
|
||||
curvedArrow: EdgeCurvedArrowProgram,
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
export type GraphViewMode = "focused" | "full";
|
||||
export type GraphLayoutSource = "provided" | "carried" | "runtime";
|
||||
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
|
||||
export type GraphLoadPhase =
|
||||
| "bootstrapping"
|
||||
| "fetching_nodes"
|
||||
| "fetching_edges"
|
||||
| "computing_styling"
|
||||
| "hydrating_scene"
|
||||
| "stabilizing_layout"
|
||||
| "ready";
|
||||
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
||||
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
|
||||
export interface GraphCameraState {
|
||||
x: number;
|
||||
y: number;
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
export interface GraphInteractionState {
|
||||
hoveredNodeId: string | null;
|
||||
selectedNodeId: string;
|
||||
selectedEdgeId: string;
|
||||
focusedNodeId: string;
|
||||
activePath: string[];
|
||||
activePathEdgeIds: string[];
|
||||
viewMode: GraphViewMode;
|
||||
zoomTier: "overview" | "structure" | "inspection";
|
||||
isLayoutRunning: boolean;
|
||||
}
|
||||
|
||||
export type GraphEffectToggle =
|
||||
| "pathPulseEnabled"
|
||||
| "pathFlowEnabled"
|
||||
| "lensEnabled"
|
||||
| "temporalEmphasisEnabled"
|
||||
| "semanticRegionsEnabled"
|
||||
| "contoursEnabled"
|
||||
| "pathfindingEnabled"
|
||||
| "communitiesEnabled"
|
||||
| "centralityEnabled"
|
||||
| "legendEnabled"
|
||||
| "diagnosticsEnabled";
|
||||
|
||||
export interface GraphEffectsState {
|
||||
pathPulseEnabled: boolean;
|
||||
pathFlowEnabled: boolean;
|
||||
lensEnabled: boolean;
|
||||
temporalEmphasisEnabled: boolean;
|
||||
semanticRegionsEnabled: boolean;
|
||||
contoursEnabled: boolean;
|
||||
pathfindingEnabled: boolean;
|
||||
communitiesEnabled: boolean;
|
||||
centralityEnabled: boolean;
|
||||
legendEnabled: boolean;
|
||||
diagnosticsEnabled: boolean;
|
||||
lensMode: "neighborhood";
|
||||
effectQuality: "bounded";
|
||||
}
|
||||
|
||||
export interface GraphEffectAvailability {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
reason: string;
|
||||
detail?: string;
|
||||
visibleSegments?: number;
|
||||
segmentCap?: number;
|
||||
}
|
||||
|
||||
export interface GraphDiagnosticsSnapshot {
|
||||
interactionState: GraphInteractionState;
|
||||
activePluginIds: string[];
|
||||
openPanelIds: string[];
|
||||
effectsState: GraphEffectsState;
|
||||
effectAvailability: {
|
||||
pathPulse: GraphEffectAvailability;
|
||||
pathFlow: GraphEffectAvailability;
|
||||
lens: GraphEffectAvailability;
|
||||
temporalEmphasis: GraphEffectAvailability;
|
||||
semanticRegions: GraphEffectAvailability;
|
||||
contours: GraphEffectAvailability;
|
||||
pathfinding: GraphEffectAvailability;
|
||||
communities: GraphEffectAvailability;
|
||||
centrality: GraphEffectAvailability;
|
||||
legend: GraphEffectAvailability;
|
||||
diagnostics: GraphEffectAvailability;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GraphTemporalState {
|
||||
currentTime: Date | null;
|
||||
activeNodeCount: number | null;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
}
|
||||
|
||||
export interface GraphDirectedPathSnapshot {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
sourceId: string | null;
|
||||
targetId: string | null;
|
||||
path: string[];
|
||||
length: number | null;
|
||||
verifiedAgainstActivePath: boolean;
|
||||
}
|
||||
|
||||
export interface GraphCommunitySummary {
|
||||
communityId: string;
|
||||
nodeCount: number;
|
||||
visibleNodeCount: number;
|
||||
dominantSemanticGroup: string;
|
||||
color: string;
|
||||
anchorNodeId: string | null;
|
||||
anchorLabel: string;
|
||||
prominence: number;
|
||||
}
|
||||
|
||||
export interface GraphSemanticRegionSummary {
|
||||
semanticGroup: string;
|
||||
nodeCount: number;
|
||||
visibleNodeCount: number;
|
||||
color: string;
|
||||
anchorNodeId: string | null;
|
||||
anchorLabel: string;
|
||||
dominantCommunityId: string | null;
|
||||
prominence: number;
|
||||
}
|
||||
|
||||
export interface GraphCentralityNodeSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
semanticGroup: string;
|
||||
color: string;
|
||||
degree: number;
|
||||
betweenness: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface GraphOverviewBackboneSnapshot {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
edgeIds: string[];
|
||||
}
|
||||
|
||||
export interface GraphAnalyticsSnapshot {
|
||||
generatedAt: number;
|
||||
directedPath: GraphDirectedPathSnapshot;
|
||||
communities: {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
count: number;
|
||||
modularity: number | null;
|
||||
summaries: GraphCommunitySummary[];
|
||||
};
|
||||
centrality: {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
topNodes: GraphCentralityNodeSummary[];
|
||||
};
|
||||
semanticRegions: {
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
summaries: GraphSemanticRegionSummary[];
|
||||
};
|
||||
overviewBackbone: GraphOverviewBackboneSnapshot;
|
||||
}
|
||||
|
||||
export interface ApiNode {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
properties: Record<string, unknown>;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiEdge {
|
||||
id: string;
|
||||
familyId: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
weight: number;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GraphLoadSummary {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
loadTimeMs: number;
|
||||
hasCoordinates?: boolean;
|
||||
layoutSource?: GraphLayoutSource;
|
||||
layoutReady?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphLoadProgress {
|
||||
phase: GraphLoadPhase;
|
||||
title: string;
|
||||
nodesLoaded: number;
|
||||
nodesTotal: number | null;
|
||||
edgesLoaded: number;
|
||||
edgesTotal: number | null;
|
||||
message: string;
|
||||
progressKind: GraphLoadProgressKind;
|
||||
loaded: number | null;
|
||||
total: number | null;
|
||||
showGraphBehind: boolean;
|
||||
stageIndex?: number;
|
||||
stageCount?: number;
|
||||
layoutSource?: GraphLayoutSource;
|
||||
layoutState?: GraphLayoutState;
|
||||
}
|
||||
|
||||
export interface GraphDataSnapshot {
|
||||
nodes: ApiNode[];
|
||||
edges: ApiEdge[];
|
||||
summary: GraphLoadSummary;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export interface GraphLayoutStatus {
|
||||
state: GraphLayoutState;
|
||||
source: GraphLayoutSource;
|
||||
hasCoordinates: boolean;
|
||||
layoutReady: boolean;
|
||||
displacement: number | null;
|
||||
elapsedMs: number;
|
||||
stableSamples: number;
|
||||
timedOut?: boolean;
|
||||
}
|
||||
|
||||
export type GraphPath = string[];
|
||||
|
||||
export interface GraphSelectedNodeState {
|
||||
id: string;
|
||||
label: string;
|
||||
content: string;
|
||||
nodeType: string;
|
||||
color?: string;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
neighborCount: number;
|
||||
}
|
||||
|
||||
export interface GraphSelectedEdgeState {
|
||||
id: string;
|
||||
familyId: string;
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
targetId: string;
|
||||
targetLabel: string;
|
||||
edgeType: string;
|
||||
weight: number;
|
||||
properties: Record<string, unknown>;
|
||||
provenanceCount: number;
|
||||
familySize: number;
|
||||
siblingCount: number;
|
||||
}
|
||||
|
||||
export interface GraphStageHandle {
|
||||
fitView: () => void;
|
||||
focusNode: (nodeId: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import type { ApiEdge, ApiNode, GraphDataSnapshot, GraphLoadProgress, GraphLayoutSource } from "./types";
|
||||
|
||||
interface NodeListResponse {
|
||||
nodes: ApiNode[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
next_cursor?: string | null;
|
||||
}
|
||||
|
||||
interface EdgeListResponse {
|
||||
edges: ApiEdge[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
next_cursor?: string | null;
|
||||
}
|
||||
|
||||
const PAGE_LIMIT = 1000;
|
||||
|
||||
async function fetchAllNodes(
|
||||
signal: AbortSignal,
|
||||
onProgress?: (progress: GraphLoadProgress) => void,
|
||||
): Promise<ApiNode[]> {
|
||||
let cursor: string | null = null;
|
||||
const collected: ApiNode[] = [];
|
||||
let total: number | null = null;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("/api/graph/nodes", window.location.origin);
|
||||
url.searchParams.set("limit", String(PAGE_LIMIT));
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: NodeListResponse = await response.json();
|
||||
if (!data.nodes?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
total = data.total ?? total;
|
||||
collected.push(...data.nodes);
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "fetching_nodes",
|
||||
progressKind: total ? "determinate" : "indeterminate",
|
||||
loaded: collected.length,
|
||||
total,
|
||||
nodesLoaded: collected.length,
|
||||
nodesTotal: total,
|
||||
edgesLoaded: 0,
|
||||
edgesTotal: null,
|
||||
message: total
|
||||
? `Loading nodes ${collected.length.toLocaleString()} of ${total.toLocaleString()}`
|
||||
: `Loading nodes ${collected.length.toLocaleString()}`,
|
||||
}));
|
||||
|
||||
if (!data.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = data.next_cursor;
|
||||
await yieldToMain();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
async function fetchAllEdges(
|
||||
signal: AbortSignal,
|
||||
nodeIds: Set<string>,
|
||||
nodeProgress: { loaded: number; total: number | null },
|
||||
onProgress?: (progress: GraphLoadProgress) => void,
|
||||
): Promise<ApiEdge[]> {
|
||||
let cursor: string | null = null;
|
||||
const collected: ApiEdge[] = [];
|
||||
const seenEdgeIds = new Set<string>();
|
||||
let total: number | null = null;
|
||||
let warnedOverTotal = false;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("/api/graph/edges", window.location.origin);
|
||||
url.searchParams.set("limit", String(PAGE_LIMIT));
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: EdgeListResponse = await response.json();
|
||||
if (!data.edges?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
total = data.total ?? total;
|
||||
const validEdges = data.edges.filter((edge) => {
|
||||
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
||||
return false;
|
||||
}
|
||||
if (seenEdgeIds.has(edge.id)) {
|
||||
return false;
|
||||
}
|
||||
seenEdgeIds.add(edge.id);
|
||||
return true;
|
||||
});
|
||||
collected.push(...validEdges);
|
||||
const safeLoaded = total ? Math.min(seenEdgeIds.size, total) : seenEdgeIds.size;
|
||||
if (!warnedOverTotal && total !== null && seenEdgeIds.size > total) {
|
||||
warnedOverTotal = true;
|
||||
console.warn("[graph-runtime] edge pagination returned more unique edge ids than total", {
|
||||
uniqueEdgesLoaded: seenEdgeIds.size,
|
||||
total,
|
||||
});
|
||||
}
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "fetching_edges",
|
||||
progressKind: total ? "determinate" : "indeterminate",
|
||||
loaded: safeLoaded,
|
||||
total,
|
||||
nodesLoaded: nodeProgress.loaded,
|
||||
nodesTotal: nodeProgress.total,
|
||||
edgesLoaded: safeLoaded,
|
||||
edgesTotal: total,
|
||||
message: total
|
||||
? `Loading edges ${safeLoaded.toLocaleString()} of ${total.toLocaleString()}`
|
||||
: `Loading edges ${safeLoaded.toLocaleString()}`,
|
||||
}));
|
||||
|
||||
if (!data.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = data.next_cursor;
|
||||
await yieldToMain();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function yieldToMain(): Promise<void> {
|
||||
if ("scheduler" in window && typeof (window as Window & { scheduler?: { yield?: () => Promise<void> } }).scheduler?.yield === "function") {
|
||||
return (window as Window & { scheduler: { yield: () => Promise<void> } }).scheduler.yield();
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function hasUsableCoordinate(value: number | null | undefined): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
interface UseGraphDataOptions {
|
||||
enabled?: boolean;
|
||||
onProgress?: (progress: GraphLoadProgress) => void;
|
||||
}
|
||||
|
||||
export function useGraphData(options: UseGraphDataOptions = {}) {
|
||||
const { enabled = true, onProgress } = options;
|
||||
|
||||
return useQuery<GraphDataSnapshot>({
|
||||
queryKey: ["graph", "runtime-snapshot"],
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
queryFn: async ({ signal }): Promise<GraphDataSnapshot> => {
|
||||
const startedAt = performance.now();
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "bootstrapping",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: 0,
|
||||
nodesTotal: null,
|
||||
edgesLoaded: 0,
|
||||
edgesTotal: null,
|
||||
message: "Preparing graph session",
|
||||
}));
|
||||
|
||||
const nodes = await fetchAllNodes(signal, onProgress);
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edges = await fetchAllEdges(
|
||||
signal,
|
||||
nodeIds,
|
||||
{ loaded: nodes.length, total: nodes.length },
|
||||
onProgress,
|
||||
);
|
||||
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "hydrating_scene",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: nodes.length,
|
||||
nodesTotal: nodes.length,
|
||||
edgesLoaded: edges.length,
|
||||
edgesTotal: edges.length,
|
||||
message: "Preparing graph runtime snapshot",
|
||||
}));
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
summary: {
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length,
|
||||
loadTimeMs: Math.round(performance.now() - startedAt),
|
||||
hasCoordinates: nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)),
|
||||
layoutSource: (nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y))
|
||||
? "provided"
|
||||
: "runtime") as GraphLayoutSource,
|
||||
layoutReady: nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)),
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReloadGraphData() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: ["graph", "runtime-snapshot"] });
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { batchMergeEdges, batchMergeNodes, clearGraph } from "../../store/graphStore";
|
||||
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
||||
import { curveGroupForPair, pairRegistryKey } from "../../store/edgePairKeys.js";
|
||||
import {
|
||||
GRAPH_THEME,
|
||||
clamp,
|
||||
darkenHex,
|
||||
hashString,
|
||||
withAlpha,
|
||||
type GraphBadgeKind,
|
||||
type GraphEdgeVariant,
|
||||
type GraphLabelVisibilityPolicy,
|
||||
type GraphNodeShapeVariant,
|
||||
} from "./graphTheme";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import type { GraphLoadProgress, GraphLoadSummary } from "./types";
|
||||
|
||||
const SEMANTIC_COLOR_FIELDS = [
|
||||
"community",
|
||||
"cluster",
|
||||
"module",
|
||||
"group",
|
||||
"category",
|
||||
"domain",
|
||||
"layer",
|
||||
"source",
|
||||
"nodeType",
|
||||
] as const;
|
||||
|
||||
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||
|
||||
function getSemanticFieldValue(attributes: NodeAttributes, field: (typeof SEMANTIC_COLOR_FIELDS)[number]): string | null {
|
||||
if (field === "nodeType") {
|
||||
const value = attributes.nodeType;
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
const value = attributes.properties?.[field];
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function normalizedEntropy(counts: number[], total: number): number {
|
||||
if (counts.length <= 1 || total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let entropy = 0;
|
||||
for (const count of counts) {
|
||||
const probability = count / total;
|
||||
entropy -= probability * Math.log(probability);
|
||||
}
|
||||
|
||||
return entropy / Math.log(counts.length);
|
||||
}
|
||||
|
||||
function chooseColorAccessor(
|
||||
nodes: Array<{ id: string; attributes: NodeAttributes }>,
|
||||
): (nodeId: string, attributes: NodeAttributes) => string {
|
||||
let bestField: (typeof SEMANTIC_COLOR_FIELDS)[number] | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const field of SEMANTIC_COLOR_FIELDS) {
|
||||
const counts = new Map<string, number>();
|
||||
let covered = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const value = getSemanticFieldValue(node.attributes, field);
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
covered += 1;
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const uniqueCount = counts.size;
|
||||
if (covered === 0 || uniqueCount <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const countValues = [...counts.values()];
|
||||
const coverage = covered / nodes.length;
|
||||
const dominantRatio = Math.max(...countValues) / covered;
|
||||
const entropy = normalizedEntropy(countValues, covered);
|
||||
const diversity = Math.min(uniqueCount, GRAPH_THEME.palette.semantic.length) / GRAPH_THEME.palette.semantic.length;
|
||||
const score = entropy * 0.65 + diversity * 0.2 + coverage * 0.15;
|
||||
|
||||
const isInformative =
|
||||
coverage >= 0.45 &&
|
||||
entropy >= 0.45 &&
|
||||
dominantRatio <= 0.88;
|
||||
|
||||
if (!isInformative) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestField = field;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestField) {
|
||||
return (_nodeId: string, attributes: NodeAttributes) =>
|
||||
getSemanticFieldValue(attributes, bestField) ?? structuralColorKey(_nodeId, attributes);
|
||||
}
|
||||
|
||||
return (nodeId: string, attributes: NodeAttributes) => structuralColorKey(nodeId, attributes);
|
||||
}
|
||||
|
||||
function structuralColorKey(nodeId: string, attributes: NodeAttributes): string {
|
||||
const shard = hashString(nodeId) % GRAPH_THEME.palette.semantic.length;
|
||||
return `${attributes.nodeType || "entity"}:${shard}`;
|
||||
}
|
||||
|
||||
function readFiniteCoordinate(value: unknown): number | null {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function seededUnit(value: string): number {
|
||||
return (hashString(value) % 10000) / 10000;
|
||||
}
|
||||
|
||||
function buildClusterSeedPositions(
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
semanticGroup: string;
|
||||
priority: number;
|
||||
}>,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const grouped = new Map<string, Array<{ id: string; priority: number }>>();
|
||||
nodes.forEach((node) => {
|
||||
const entry = grouped.get(node.semanticGroup);
|
||||
if (entry) {
|
||||
entry.push({ id: node.id, priority: node.priority });
|
||||
} else {
|
||||
grouped.set(node.semanticGroup, [{ id: node.id, priority: node.priority }]);
|
||||
}
|
||||
});
|
||||
|
||||
const groupEntries = [...grouped.entries()]
|
||||
.sort((left, right) => {
|
||||
const countDelta = right[1].length - left[1].length;
|
||||
if (countDelta !== 0) {
|
||||
return countDelta;
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
});
|
||||
|
||||
const groupCenters = new Map<string, { x: number; y: number; spread: number }>();
|
||||
groupEntries.forEach(([group, members], index) => {
|
||||
const angle = index * 2.399963229728653;
|
||||
const radius = 170 + Math.sqrt(index + 1) * 195;
|
||||
const spread = 72 + Math.sqrt(members.length) * 16;
|
||||
groupCenters.set(group, {
|
||||
x: Math.cos(angle) * radius * 1.14,
|
||||
y: Math.sin(angle) * radius * 0.84,
|
||||
spread,
|
||||
});
|
||||
});
|
||||
|
||||
const seeded = new Map<string, { x: number; y: number }>();
|
||||
groupEntries.forEach(([group, members]) => {
|
||||
const center = groupCenters.get(group);
|
||||
if (!center) {
|
||||
return;
|
||||
}
|
||||
|
||||
members
|
||||
.sort((left, right) => {
|
||||
if (right.priority !== left.priority) {
|
||||
return right.priority - left.priority;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
})
|
||||
.forEach((member, index) => {
|
||||
const angle = index * 2.399963229728653 + seededUnit(`${group}:${member.id}:angle`) * 0.72;
|
||||
const radial = Math.sqrt((index + 0.5) / Math.max(members.length, 1)) * center.spread;
|
||||
const jitterX = (seededUnit(`${member.id}:jx`) - 0.5) * center.spread * 0.22;
|
||||
const jitterY = (seededUnit(`${member.id}:jy`) - 0.5) * center.spread * 0.18;
|
||||
seeded.set(member.id, {
|
||||
x: center.x + Math.cos(angle) * radial + jitterX,
|
||||
y: center.y + Math.sin(angle) * radial * 0.86 + jitterY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function getProvenanceCount(properties: Record<string, unknown>): number {
|
||||
return PROVENANCE_KEYS.reduce(
|
||||
(count, key) => (properties[key] !== undefined && properties[key] !== null ? count + 1 : count),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNodeVariantMetadata(
|
||||
baseColor: string,
|
||||
sizeRatio: number,
|
||||
hasTemporalBounds: boolean,
|
||||
provenanceCount: number,
|
||||
): Pick<
|
||||
NodeAttributes,
|
||||
"nodeVariant" | "nodeShapeVariant" | "badgeKind" | "badgeCount" | "ringColor" | "haloColor" | "labelVisibilityPolicy"
|
||||
> {
|
||||
let nodeShapeVariant: GraphNodeShapeVariant = "default";
|
||||
let badgeKind: GraphBadgeKind | undefined;
|
||||
let badgeCount: number | undefined;
|
||||
|
||||
if (hasTemporalBounds) {
|
||||
nodeShapeVariant = "temporal";
|
||||
badgeKind = "temporal";
|
||||
} else if (provenanceCount > 0) {
|
||||
nodeShapeVariant = "provenance";
|
||||
badgeKind = "provenance";
|
||||
badgeCount = provenanceCount;
|
||||
}
|
||||
|
||||
let labelVisibilityPolicy: GraphLabelVisibilityPolicy = "none";
|
||||
if (sizeRatio >= 0.86) {
|
||||
labelVisibilityPolicy = "always";
|
||||
} else if (badgeKind) {
|
||||
labelVisibilityPolicy = "local";
|
||||
} else if (sizeRatio >= 0.56) {
|
||||
labelVisibilityPolicy = "priority";
|
||||
}
|
||||
|
||||
return {
|
||||
nodeVariant: nodeShapeVariant,
|
||||
nodeShapeVariant,
|
||||
badgeKind,
|
||||
badgeCount,
|
||||
ringColor: GRAPH_THEME.nodes.selectedRing.color,
|
||||
haloColor: withAlpha(baseColor, 0.38),
|
||||
labelVisibilityPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEdgeVariantMetadata(
|
||||
edge: ApiEdge,
|
||||
sourcePriority: number,
|
||||
targetPriority: number,
|
||||
isBidirectional: boolean,
|
||||
): Pick<
|
||||
EdgeAttributes,
|
||||
"edgeVariant" | "arrowVisibilityPolicy" | "relationshipStrength" | "isParallelPair" | "parallelIndex" | "parallelCount"
|
||||
> {
|
||||
const relationshipStrength = clamp(
|
||||
0.12,
|
||||
0.18 + Math.log(Math.max(Number(edge.weight) || 1, 1)) / Math.log(12),
|
||||
1,
|
||||
);
|
||||
|
||||
let edgeVariant: GraphEdgeVariant = "line";
|
||||
if (isBidirectional) {
|
||||
edgeVariant = "bidirectionalCurve";
|
||||
} else if (Math.max(sourcePriority, targetPriority, relationshipStrength) >= 0.58) {
|
||||
edgeVariant = "directional";
|
||||
}
|
||||
|
||||
return {
|
||||
edgeVariant,
|
||||
arrowVisibilityPolicy: edgeVariant === "line" ? "hidden" : "contextual",
|
||||
relationshipStrength,
|
||||
isParallelPair: false,
|
||||
parallelIndex: 0,
|
||||
parallelCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiNode {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
properties: Record<string, unknown>;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
}
|
||||
|
||||
interface ApiEdge {
|
||||
id: string;
|
||||
familyId: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
weight: number;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface NodeListResponse {
|
||||
nodes: ApiNode[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
next_cursor?: string | null;
|
||||
}
|
||||
|
||||
interface EdgeListResponse {
|
||||
edges: ApiEdge[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
next_cursor?: string | null;
|
||||
}
|
||||
|
||||
const PAGE_LIMIT = 1000;
|
||||
|
||||
async function fetchAllNodes(
|
||||
signal: AbortSignal,
|
||||
onProgress?: (progress: GraphLoadProgress) => void,
|
||||
): Promise<ApiNode[]> {
|
||||
let cursor: string | null = null;
|
||||
const collected: ApiNode[] = [];
|
||||
let total: number | null = null;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("/api/graph/nodes", window.location.origin);
|
||||
url.searchParams.set("limit", String(PAGE_LIMIT));
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: NodeListResponse = await response.json();
|
||||
if (!data.nodes?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
total = data.total ?? total;
|
||||
collected.push(...data.nodes);
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "fetching_nodes",
|
||||
progressKind: total ? "determinate" : "indeterminate",
|
||||
loaded: collected.length,
|
||||
total,
|
||||
nodesLoaded: collected.length,
|
||||
nodesTotal: total,
|
||||
edgesLoaded: 0,
|
||||
edgesTotal: null,
|
||||
message: total
|
||||
? `Loading nodes ${collected.length.toLocaleString()} of ${total.toLocaleString()}`
|
||||
: `Loading nodes ${collected.length.toLocaleString()}`,
|
||||
}));
|
||||
|
||||
if (!data.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = data.next_cursor;
|
||||
await yieldToMain();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
async function fetchAllEdges(
|
||||
signal: AbortSignal,
|
||||
nodeIds: Set<string>,
|
||||
nodeProgress: { loaded: number; total: number | null },
|
||||
onProgress?: (progress: GraphLoadProgress) => void,
|
||||
): Promise<ApiEdge[]> {
|
||||
let cursor: string | null = null;
|
||||
const collected: ApiEdge[] = [];
|
||||
const seenEdgeIds = new Set<string>();
|
||||
let total: number | null = null;
|
||||
let warnedOverTotal = false;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("/api/graph/edges", window.location.origin);
|
||||
url.searchParams.set("limit", String(PAGE_LIMIT));
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: EdgeListResponse = await response.json();
|
||||
if (!data.edges?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
total = data.total ?? total;
|
||||
const validEdges = data.edges.filter((edge) => {
|
||||
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
||||
return false;
|
||||
}
|
||||
if (seenEdgeIds.has(edge.id)) {
|
||||
return false;
|
||||
}
|
||||
seenEdgeIds.add(edge.id);
|
||||
return true;
|
||||
});
|
||||
collected.push(...validEdges);
|
||||
const safeLoaded = total ? Math.min(seenEdgeIds.size, total) : seenEdgeIds.size;
|
||||
if (!warnedOverTotal && total !== null && seenEdgeIds.size > total) {
|
||||
warnedOverTotal = true;
|
||||
console.warn("[graph-load] edge pagination returned more unique edge ids than total", {
|
||||
uniqueEdgesLoaded: seenEdgeIds.size,
|
||||
total,
|
||||
});
|
||||
}
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "fetching_edges",
|
||||
progressKind: total ? "determinate" : "indeterminate",
|
||||
loaded: safeLoaded,
|
||||
total,
|
||||
nodesLoaded: nodeProgress.loaded,
|
||||
nodesTotal: nodeProgress.total,
|
||||
edgesLoaded: safeLoaded,
|
||||
edgesTotal: total,
|
||||
message: total
|
||||
? `Loading edges ${safeLoaded.toLocaleString()} of ${total.toLocaleString()}`
|
||||
: `Loading edges ${safeLoaded.toLocaleString()}`,
|
||||
}));
|
||||
|
||||
if (!data.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = data.next_cursor;
|
||||
await yieldToMain();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function yieldToMain(): Promise<void> {
|
||||
if ("scheduler" in window && typeof (window as Window & { scheduler?: { yield?: () => Promise<void> } }).scheduler?.yield === "function") {
|
||||
return (window as Window & { scheduler: { yield: () => Promise<void> } }).scheduler.yield();
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
interface UseLoadGraphOptions {
|
||||
enabled?: boolean;
|
||||
onGraphReady?: (summary: GraphLoadSummary) => void;
|
||||
onProgress?: (progress: GraphLoadProgress) => void;
|
||||
}
|
||||
|
||||
export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
const { enabled = true, onGraphReady, onProgress } = options;
|
||||
|
||||
return useQuery<GraphLoadSummary>({
|
||||
queryKey: ["graph", "full-load"],
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
retry: 0,
|
||||
queryFn: async ({ signal }): Promise<GraphLoadSummary> => {
|
||||
const startedAt = performance.now();
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "bootstrapping",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: 0,
|
||||
nodesTotal: null,
|
||||
edgesLoaded: 0,
|
||||
edgesTotal: null,
|
||||
message: "Preparing graph session",
|
||||
}));
|
||||
|
||||
const fetchedNodes = await fetchAllNodes(signal, onProgress);
|
||||
const nodeIds = new Set(fetchedNodes.map((node) => node.id));
|
||||
const fetchedEdges = await fetchAllEdges(
|
||||
signal,
|
||||
nodeIds,
|
||||
{ loaded: fetchedNodes.length, total: fetchedNodes.length },
|
||||
onProgress,
|
||||
);
|
||||
|
||||
const degreeByNode = new Map<string, number>();
|
||||
for (const nodeId of nodeIds) {
|
||||
degreeByNode.set(nodeId, 0);
|
||||
}
|
||||
for (const edge of fetchedEdges) {
|
||||
degreeByNode.set(edge.source, (degreeByNode.get(edge.source) ?? 0) + 1);
|
||||
degreeByNode.set(edge.target, (degreeByNode.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const maxDegree = Math.max(...degreeByNode.values(), 1);
|
||||
const draftAttributes = fetchedNodes.map((node) => ({
|
||||
id: node.id,
|
||||
attributes: {
|
||||
label: node.content || node.id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
nodeType: node.type,
|
||||
content: node.content,
|
||||
valid_from: node.valid_from,
|
||||
valid_until: node.valid_until,
|
||||
properties: node.properties,
|
||||
} as NodeAttributes,
|
||||
}));
|
||||
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "computing_styling",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: fetchedNodes.length,
|
||||
nodesTotal: fetchedNodes.length,
|
||||
edgesLoaded: fetchedEdges.length,
|
||||
edgesTotal: fetchedEdges.length,
|
||||
message: "Applying semantic color, sizing, and structural styling",
|
||||
}));
|
||||
|
||||
const colorAccessor = chooseColorAccessor(draftAttributes);
|
||||
const nodePriorityById = new Map<string, number>();
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const degree = degreeByNode.get(nodeId) ?? 0;
|
||||
const sizeRatio = Math.log(degree + 1) / Math.log(maxDegree + 1);
|
||||
nodePriorityById.set(nodeId, sizeRatio);
|
||||
}
|
||||
|
||||
const semanticKeyByNodeId = new Map<string, string>();
|
||||
draftAttributes.forEach(({ id, attributes }) => {
|
||||
semanticKeyByNodeId.set(id, colorAccessor(id, attributes));
|
||||
});
|
||||
|
||||
const providedCoordinateCount = fetchedNodes.reduce((count, node) => {
|
||||
const properties = node.properties as Record<string, unknown>;
|
||||
return readFiniteCoordinate(properties?.x) !== null && readFiniteCoordinate(properties?.y) !== null
|
||||
? count + 1
|
||||
: count;
|
||||
}, 0);
|
||||
const coordinateCoverage = fetchedNodes.length > 0 ? providedCoordinateCount / fetchedNodes.length : 0;
|
||||
const useProvidedCoordinates = coordinateCoverage >= 0.92;
|
||||
const seededPositions = useProvidedCoordinates
|
||||
? null
|
||||
: buildClusterSeedPositions(
|
||||
draftAttributes.map(({ id, attributes }) => ({
|
||||
id,
|
||||
semanticGroup: semanticKeyByNodeId.get(id) ?? structuralColorKey(id, attributes),
|
||||
priority: nodePriorityById.get(id) ?? 0,
|
||||
})),
|
||||
);
|
||||
|
||||
const nodesToMerge = draftAttributes.map(({ id, attributes }) => {
|
||||
const semanticGroup = semanticKeyByNodeId.get(id) ?? colorAccessor(id, attributes);
|
||||
const colorIndex = hashString(semanticGroup) % GRAPH_THEME.palette.semantic.length;
|
||||
const baseColor = GRAPH_THEME.palette.semantic[colorIndex];
|
||||
const sizeRatio = nodePriorityById.get(id) ?? 0;
|
||||
const dynamicSize = clamp(1.8, 1.8 + 8.8 * sizeRatio, 11.8);
|
||||
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
|
||||
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
|
||||
const properties = attributes.properties as Record<string, unknown>;
|
||||
const providedX = readFiniteCoordinate(properties?.x);
|
||||
const providedY = readFiniteCoordinate(properties?.y);
|
||||
const seededPosition = seededPositions?.get(id);
|
||||
const x = useProvidedCoordinates
|
||||
? providedX ?? 0
|
||||
: providedX ?? seededPosition?.x ?? 0;
|
||||
const y = useProvidedCoordinates
|
||||
? providedY ?? 0
|
||||
: providedY ?? seededPosition?.y ?? 0;
|
||||
return {
|
||||
id,
|
||||
attributes: {
|
||||
...attributes,
|
||||
x,
|
||||
y,
|
||||
semanticGroup,
|
||||
color: baseColor,
|
||||
baseColor,
|
||||
mutedColor: withAlpha(baseColor, GRAPH_THEME.nodes.mutedAlpha),
|
||||
glowColor: withAlpha(baseColor, 0.24),
|
||||
size: dynamicSize,
|
||||
baseSize: dynamicSize,
|
||||
visualPriority: sizeRatio,
|
||||
labelPriority: sizeRatio,
|
||||
strokeColor: darkenHex(baseColor, 112),
|
||||
borderColor: darkenHex(baseColor, 112),
|
||||
borderSize: 0.72,
|
||||
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
|
||||
} as NodeAttributes,
|
||||
};
|
||||
});
|
||||
|
||||
const edgeKeys = new Set(fetchedEdges.map((edge) => pairRegistryKey(edge.source, edge.target)));
|
||||
const parallelCounts = new Map<string, number>();
|
||||
const familyCounts = new Map<string, number>();
|
||||
fetchedEdges.forEach((edge) => {
|
||||
const pairKey = pairRegistryKey(edge.source, edge.target);
|
||||
parallelCounts.set(pairKey, (parallelCounts.get(pairKey) ?? 0) + 1);
|
||||
familyCounts.set(edge.familyId, (familyCounts.get(edge.familyId) ?? 0) + 1);
|
||||
});
|
||||
const parallelOffsets = new Map<string, number>();
|
||||
|
||||
const edgesToMerge = fetchedEdges.map((edge) => {
|
||||
const sourcePriority = nodePriorityById.get(edge.source) ?? 0;
|
||||
const targetPriority = nodePriorityById.get(edge.target) ?? 0;
|
||||
const isBidirectional = edgeKeys.has(pairRegistryKey(edge.target, edge.source));
|
||||
const pairKey = pairRegistryKey(edge.source, edge.target);
|
||||
const parallelIndex = parallelOffsets.get(pairKey) ?? 0;
|
||||
parallelOffsets.set(pairKey, parallelIndex + 1);
|
||||
const parallelCount = parallelCounts.get(pairKey) ?? 1;
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
familyId: edge.familyId,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
attributes: {
|
||||
edgeId: edge.id,
|
||||
familyId: edge.familyId,
|
||||
sourceId: edge.source,
|
||||
targetId: edge.target,
|
||||
weight: edge.weight,
|
||||
edgeType: edge.type,
|
||||
properties: edge.properties,
|
||||
size: clamp(0.18, 0.22 + Math.sqrt(Math.max(Number(edge.weight) || 1, 1)) * 0.2, 0.88),
|
||||
baseSize: clamp(0.18, 0.22 + Math.sqrt(Math.max(Number(edge.weight) || 1, 1)) * 0.2, 0.88),
|
||||
color: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
baseColor: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
|
||||
visualPriority: Math.max(sourcePriority, targetPriority),
|
||||
isBidirectional,
|
||||
edgeFamily: isBidirectional ? "bidirectional" : "line",
|
||||
curveGroup: curveGroupForPair(edge.source, edge.target),
|
||||
type: "line",
|
||||
isParallelPair: parallelCount > 1,
|
||||
parallelIndex,
|
||||
parallelCount,
|
||||
familySize: familyCounts.get(edge.familyId) ?? 1,
|
||||
...resolveEdgeVariantMetadata(edge, sourcePriority, targetPriority, isBidirectional),
|
||||
} as EdgeAttributes,
|
||||
};
|
||||
});
|
||||
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: "hydrating_scene",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: nodesToMerge.length,
|
||||
nodesTotal: nodesToMerge.length,
|
||||
edgesLoaded: edgesToMerge.length,
|
||||
edgesTotal: edgesToMerge.length,
|
||||
message: "Preparing renderer and hydrating graph scene",
|
||||
}));
|
||||
|
||||
try {
|
||||
clearGraph();
|
||||
} catch (error) {
|
||||
console.error("[graph-load] clearGraph failed", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
batchMergeNodes(nodesToMerge);
|
||||
} catch (error) {
|
||||
console.error("[graph-load] batchMergeNodes failed", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
batchMergeEdges(edgesToMerge);
|
||||
} catch (error) {
|
||||
console.error("[graph-load] batchMergeEdges failed", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
nodeCount: nodesToMerge.length,
|
||||
edgeCount: edgesToMerge.length,
|
||||
loadTimeMs: Math.round(performance.now() - startedAt),
|
||||
hasCoordinates: useProvidedCoordinates,
|
||||
layoutSource: useProvidedCoordinates ? "provided" : "runtime",
|
||||
layoutReady: useProvidedCoordinates,
|
||||
} satisfies GraphLoadSummary;
|
||||
|
||||
onProgress?.(createGraphLoadProgress({
|
||||
phase: summary.layoutReady ? "ready" : "stabilizing_layout",
|
||||
progressKind: "indeterminate",
|
||||
nodesLoaded: summary.nodeCount,
|
||||
nodesTotal: summary.nodeCount,
|
||||
edgesLoaded: summary.edgeCount,
|
||||
edgesTotal: summary.edgeCount,
|
||||
message: summary.layoutReady ? "Graph ready" : "Settling runtime layout",
|
||||
showGraphBehind: !summary.layoutReady,
|
||||
layoutSource: summary.layoutSource,
|
||||
layoutState: summary.layoutReady ? "interactive" : "bootstrapping",
|
||||
}));
|
||||
|
||||
onGraphReady?.(summary);
|
||||
return summary;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReloadGraph() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { UploadCloud, Download, FileJson, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
.dropzone {
|
||||
border: 2px dashed rgba(88,166,255,0.4);
|
||||
border-radius: 12px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropzone:hover, .dropzone.active {
|
||||
border-color: #58a6ff;
|
||||
background: rgba(88,166,255,0.05);
|
||||
}
|
||||
.btn-primary {
|
||||
background: #238636;
|
||||
color: #fff;
|
||||
border: 1px solid rgba(240,246,252,0.1);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2ea043;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.toast {
|
||||
animation: slideUp 0.3s ease-out forwards;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`;
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function ImportExportWorkspace() {
|
||||
// Import State
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// Export State
|
||||
const [exportFormat, setExportFormat] = useState<"json" | "csv">("json");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Toasts
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const showToast = (type: "success" | "error", text: string) => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, type, text }]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setFile(acceptedFiles[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/json': ['.json'],
|
||||
'text/csv': ['.csv']
|
||||
},
|
||||
maxFiles: 1
|
||||
});
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return;
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/api/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Import failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
showToast("success", `Imported ${data.nodes_imported} nodes and ${data.edges_imported} edges!`);
|
||||
setFile(null);
|
||||
} catch (err: any) {
|
||||
showToast("error", err.message || "An error occurred during import");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const res = await fetch("/api/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ format: exportFormat })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Export failed");
|
||||
}
|
||||
|
||||
// Handle file download
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
// Provide a default extension based on format
|
||||
a.download = `semantica_export.${exportFormat}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showToast("success", "Export complete! Your download should begin shortly.");
|
||||
} catch (err: any) {
|
||||
showToast("error", err.message || "An error occurred during export");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 32, gap: 24, boxSizing: "border-box", overflowY: "auto" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 8px 0", color: "#fff", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<UploadCloud size={28} color="#58a6ff" /> Data Import / Export
|
||||
</h1>
|
||||
<p style={{ margin: 0, color: "#8b949e" }}>Ingest new graph datasets or extract the current knowledge base.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
|
||||
|
||||
{/* IMPORT PANEL */}
|
||||
<div className="glass-panel" style={{ flex: "1 1 400px", borderRadius: 12, padding: 32, display: "flex", flexDirection: "column" }}>
|
||||
<h2 style={{ margin: "0 0 24px 0", color: "#58a6ff", fontSize: 20, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<UploadCloud size={20} /> Import Entities & Relations
|
||||
</h2>
|
||||
|
||||
<div {...getRootProps()} className={`dropzone ${isDragActive ? 'active' : ''}`} style={{ padding: 48, textAlign: "center", marginBottom: 24, flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
||||
<input {...getInputProps()} />
|
||||
{file ? (
|
||||
<>
|
||||
{file.name.endsWith(".json") ? <FileJson size={48} color="#3fb950" style={{ marginBottom: 16 }} /> : <FileText size={48} color="#3fb950" style={{ marginBottom: 16 }} />}
|
||||
<p style={{ color: "#fff", fontWeight: 600, margin: "0 0 8px 0" }}>{file.name}</p>
|
||||
<p style={{ color: "#8b949e", fontSize: 13, margin: 0 }}>{(file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud size={48} color="#58a6ff" style={{ marginBottom: 16, opacity: 0.8 }} />
|
||||
<p style={{ color: "#c9d1d9", fontSize: 16, fontWeight: 500, margin: "0 0 8px 0" }}>Drag & drop your file here</p>
|
||||
<p style={{ color: "#8b949e", fontSize: 13, margin: 0 }}>Supports .json and .csv formats</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleImport}
|
||||
disabled={!file || isUploading}
|
||||
style={{ width: "100%", padding: "12px 24px", borderRadius: 8, fontSize: 16, fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, cursor: (!file || isUploading) ? "not-allowed" : "pointer", border: "none" }}
|
||||
>
|
||||
{isUploading ? <Loader2 size={20} className="animate-spin" /> : <UploadCloud size={20} />}
|
||||
{isUploading ? "Uploading..." : "Upload to Graph"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* EXPORT PANEL */}
|
||||
<div className="glass-panel" style={{ flex: "1 1 400px", borderRadius: 12, padding: 32, display: "flex", flexDirection: "column" }}>
|
||||
<h2 style={{ margin: "0 0 24px 0", color: "#d2a8ff", fontSize: 20, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Download size={20} /> Export Graph Snapshot
|
||||
</h2>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 14, fontWeight: 500 }}>Export Format</label>
|
||||
<div style={{ position: "relative", marginBottom: 32 }}>
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={e => setExportFormat(e.target.value as "json" | "csv")}
|
||||
style={{ width: "100%", appearance: "none", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(88,166,255,0.3)", color: "#fff", padding: "12px 16px", borderRadius: 8, fontSize: 15, cursor: "pointer", outline: "none" }}
|
||||
>
|
||||
<option value="json" style={{ background: "#0d1117" }}>JSON (Full Graph Dictionary)</option>
|
||||
<option value="csv" style={{ background: "#0d1117" }}>CSV (Tabular Dump)</option>
|
||||
</select>
|
||||
<div style={{ position: "absolute", right: 16, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}>
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.41 0.589966L6 5.16997L10.59 0.589966L12 1.99997L6 7.99997L0 1.99997L1.41 0.589966Z" fill="#8b949e"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: "rgba(0,0,0,0.2)", padding: 20, borderRadius: 8, border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<h4 style={{ color: "#c9d1d9", margin: "0 0 8px 0", fontSize: 14 }}>Export Details</h4>
|
||||
<p style={{ color: "#8b949e", fontSize: 13, margin: 0, lineHeight: 1.5 }}>
|
||||
{exportFormat === "json"
|
||||
? "Exports the entire graph including all node properties, edge weights, and complete entity metadata into a standardized JSON payload."
|
||||
: "Exports a flattened CSV tabular representation of all nodes and edges. Complex nested properties will be omitted or stringified."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={{ width: "100%", background: "#1f6feb", color: "#fff", padding: "12px 24px", borderRadius: 8, fontSize: 16, fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, cursor: isExporting ? "not-allowed" : "pointer", border: "1px solid rgba(240,246,252,0.1)", transition: "background 0.2s" }}
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
onMouseOver={e => { if(!isExporting) (e.currentTarget.style.background = "#388bfd") }}
|
||||
onMouseOut={e => { if(!isExporting) (e.currentTarget.style.background = "#1f6feb") }}
|
||||
>
|
||||
{isExporting ? <Loader2 size={20} className="animate-spin" /> : <Download size={20} />}
|
||||
{isExporting ? "Preparing Extract..." : "Download Graph Extract"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<div style={{ position: "fixed", bottom: 32, right: 32, display: "flex", flexDirection: "column", gap: 12, zIndex: 1000 }}>
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className="toast" style={{
|
||||
display: "flex", alignItems: "center", gap: 12, padding: "16px 20px", borderRadius: 8,
|
||||
background: toast.type === 'success' ? '#1b4a24' : '#571822',
|
||||
border: `1px solid ${toast.type === 'success' ? 'rgba(63, 185, 80, 0.4)' : 'rgba(248, 81, 73, 0.4)'}`,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.5)"
|
||||
}}>
|
||||
{toast.type === 'success' ? <CheckCircle2 color="#3fb950" size={20}/> : <AlertCircle color="#f85149" size={20}/>}
|
||||
<span style={{ color: "#fff", fontSize: 14, fontWeight: 500 }}>{toast.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* src/workspaces/LineageWorkspace/LineageDiagram.tsx
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { ReactFlow, Background, Controls } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
const THEME_CSS = `
|
||||
.react-flow { background: #0d1117; }
|
||||
.react-flow__node-group {
|
||||
background: rgba(88,166,255,0.05);
|
||||
border: 1px dashed rgba(88,166,255,0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.react-flow__node-default {
|
||||
background: #161b22;
|
||||
color: #c9d1d9;
|
||||
border: 1px solid rgba(88,166,255,0.3);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function LineageDiagram() {
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [edges, setEdges] = useState<any[]>([]);
|
||||
const [searchId, setSearchId] = useState("");
|
||||
const [activeId, setActiveId] = useState("");
|
||||
|
||||
const downloadReport = async (format: "json" | "markdown") => {
|
||||
if (!activeId) return;
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(activeId)}&format=${format}`);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${activeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(anchor);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const xLanes = [
|
||||
{ id: "group_agent", type: "group", position: { x: 50, y: 50 }, style: { width: 800, height: 120 } },
|
||||
{ id: "group_activity", type: "group", position: { x: 50, y: 200 }, style: { width: 800, height: 120 } },
|
||||
{ id: "group_entity", type: "group", position: { x: 50, y: 350 }, style: { width: 800, height: 120 } }
|
||||
];
|
||||
|
||||
const fetchLineage = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/provenance?node_id=" + encodeURIComponent(activeId));
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`HTTP ${res.status}: API Route missing or failed.`, text.substring(0, 100));
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
console.error("Backend returned non-JSON response (likely an HTML fallback). Check FastAPI routing.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const counters: Record<string, number> = { "group_agent": 0, "group_activity": 0, "group_entity": 0 };
|
||||
|
||||
const mappedNodes = data.nodes.map((n: any) => {
|
||||
const c = counters[n.parent_id] || 0;
|
||||
counters[n.parent_id] = c + 1;
|
||||
return {
|
||||
id: n.id,
|
||||
data: { label: n.label + "\\n(" + n.prov_type + ")" },
|
||||
position: { x: 50 + c * 180, y: 30 },
|
||||
parentId: n.parent_id,
|
||||
extent: "parent",
|
||||
type: "default"
|
||||
};
|
||||
});
|
||||
|
||||
const mappedEdges = data.edges.map((e: any) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
animated: true,
|
||||
style: { stroke: "#58a6ff" }
|
||||
}));
|
||||
|
||||
setNodes([...xLanes, ...mappedNodes]);
|
||||
setEdges(mappedEdges);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
fetchLineage();
|
||||
}, [activeId]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", position: "relative", background: "#0d1117" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Top Bar Navigation */}
|
||||
<div style={{ position: "absolute", top: 16, left: 16, zIndex: 10, display: "flex", gap: "12px", alignItems: "center" }}>
|
||||
<div style={{ background: "rgba(13,17,23,0.8)", padding: "4px 8px", borderRadius: 4, color: "#fff", fontWeight: 600, border: "1px solid rgba(255,255,255,0.1)", pointerEvents: "none" }}>
|
||||
PROV-O Lineage
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Node ID..."
|
||||
value={searchId}
|
||||
onChange={(e) => setSearchId(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setActiveId(searchId);
|
||||
}}
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
border: "1px solid rgba(88,166,255,0.3)",
|
||||
color: "#c9d1d9",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
width: "200px"
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setActiveId(searchId)}
|
||||
style={{
|
||||
background: "#1f6feb",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
padding: "5px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void downloadReport("json")}
|
||||
disabled={!activeId}
|
||||
style={{
|
||||
background: "rgba(31, 111, 235, 0.18)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(88,166,255,0.3)",
|
||||
padding: "5px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: activeId ? "pointer" : "not-allowed",
|
||||
fontWeight: 500,
|
||||
opacity: activeId ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void downloadReport("markdown")}
|
||||
disabled={!activeId}
|
||||
style={{
|
||||
background: "rgba(31, 111, 235, 0.18)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(88,166,255,0.3)",
|
||||
padding: "5px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: activeId ? "pointer" : "not-allowed",
|
||||
fontWeight: 500,
|
||||
opacity: activeId ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeId ? (
|
||||
<ReactFlow nodes={nodes} edges={edges} fitView>
|
||||
<Background color="#30363d" gap={20} />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<div style={{ display: "flex", height: "100%", width: "100%", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: "14px" }}>
|
||||
Enter a Node ID to view its W3C PROV-O lineage.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const SAMPLE_FACTS = `inhibits(Metformin, mTOR)\ncauses(mTOR, Neurodegeneration)`;
|
||||
const SAMPLE_RULE = `IF inhibits(Metformin, mTOR) AND causes(mTOR, Neurodegeneration) THEN candidate(Metformin, Alzheimer's)`;
|
||||
|
||||
export function ReasoningWorkspace() {
|
||||
const queryClient = useQueryClient();
|
||||
const [facts, setFacts] = useState(SAMPLE_FACTS);
|
||||
const [rules, setRules] = useState(SAMPLE_RULE);
|
||||
const [applyToGraph, setApplyToGraph] = useState(true);
|
||||
const [result, setResult] = useState<{ inferred_facts?: string[]; rules_fired?: number; added_edges?: number; mutated?: boolean } | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleRun() {
|
||||
setIsRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await fetch("/api/reason", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
facts: facts.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
rules: rules.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
mode: "forward",
|
||||
apply_to_graph: applyToGraph,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `Reasoning failed with status ${response.status}`);
|
||||
}
|
||||
setResult(data);
|
||||
if (data.mutated) {
|
||||
queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
|
||||
}
|
||||
} catch (runError) {
|
||||
setError(runError instanceof Error ? runError.message : "Reasoning failed");
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.2fr 1fr", gap: 24, height: "100%", padding: 24, boxSizing: "border-box", background: "#0d1117" }}>
|
||||
<div style={panelStyle}>
|
||||
<h3 style={titleStyle}>Facts</h3>
|
||||
<p style={copyStyle}>Enter one fact per line using `predicate(subject, object)` form.</p>
|
||||
<textarea value={facts} onChange={(event) => setFacts(event.target.value)} style={textareaStyle} />
|
||||
|
||||
<h3 style={{ ...titleStyle, marginTop: 18 }}>Rules</h3>
|
||||
<p style={copyStyle}>Write rules in `IF ... AND ... THEN ...` format. If the advanced reasoner is unavailable, the explorer falls back to an internal rule matcher for this format.</p>
|
||||
<textarea value={rules} onChange={(event) => setRules(event.target.value)} style={{ ...textareaStyle, minHeight: 160 }} />
|
||||
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, color: "#c9d1d9", fontSize: 13, marginTop: 16 }}>
|
||||
<input type="checkbox" checked={applyToGraph} onChange={(event) => setApplyToGraph(event.target.checked)} />
|
||||
Write inferred binary facts back into the graph as inferred edges
|
||||
</label>
|
||||
|
||||
<button onClick={handleRun} disabled={isRunning} style={runButtonStyle}>
|
||||
{isRunning ? "Running..." : "Run Reasoning"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={panelStyle}>
|
||||
<h3 style={titleStyle}>Inference Results</h3>
|
||||
|
||||
{error ? <div style={{ color: "#ff7b72", marginBottom: 12 }}>{error}</div> : null}
|
||||
|
||||
{result ? (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 14 }}>
|
||||
<span style={pillStyle}>rules fired: {result.rules_fired ?? 0}</span>
|
||||
<span style={pillStyle}>edges added: {result.added_edges ?? 0}</span>
|
||||
<span style={pillStyle}>{result.mutated ? "graph updated" : "preview only"}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{(result.inferred_facts || []).length ? (
|
||||
result.inferred_facts?.map((fact) => (
|
||||
<div key={fact} style={factCardStyle}>{fact}</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", fontSize: 13 }}>No inferred facts were produced.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", fontSize: 13 }}>Run a rule set to inspect inferred statements here.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(13, 17, 23, 0.78), rgba(22, 27, 34, 0.64))",
|
||||
border: "1px solid rgba(88, 166, 255, 0.18)",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
color: "#fff",
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const copyStyle: React.CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: "8px 0 14px",
|
||||
};
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
minHeight: 120,
|
||||
resize: "vertical",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(88, 166, 255, 0.18)",
|
||||
background: "rgba(0, 0, 0, 0.25)",
|
||||
color: "#e6edf3",
|
||||
padding: 12,
|
||||
fontFamily: "Consolas, monospace",
|
||||
fontSize: 13,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const runButtonStyle: React.CSSProperties = {
|
||||
marginTop: 18,
|
||||
border: "1px solid rgba(88, 166, 255, 0.3)",
|
||||
background: "rgba(31, 111, 235, 0.2)",
|
||||
color: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "11px 14px",
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const pillStyle: React.CSSProperties = {
|
||||
color: "#79c0ff",
|
||||
border: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
borderRadius: 999,
|
||||
padding: "5px 10px",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const factCardStyle: React.CSSProperties = {
|
||||
color: "#e6edf3",
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
borderRadius: 10,
|
||||
padding: "10px 12px",
|
||||
fontFamily: "Consolas, monospace",
|
||||
fontSize: 13,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx
|
||||
*/
|
||||
import { useState, useRef } from "react";
|
||||
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
export function SparqlWorkspace() {
|
||||
const monaco = useMonaco();
|
||||
const editorRef = useRef<any>(null);
|
||||
const [query, setQuery] = useState("SELECT ?s ?p ?o\nWHERE {\n ?s ?p ?o\n}\nLIMIT 10");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
function handleEditorWillMount(monacoIns: any) {
|
||||
if (!monacoIns.languages.getLanguages().some((l: any) => l.id === "sparql")) {
|
||||
monacoIns.languages.register({ id: "sparql" });
|
||||
|
||||
monacoIns.languages.setMonarchTokensProvider("sparql", {
|
||||
keywords: ["SELECT", "WHERE", "LIMIT", "FILTER", "OPTIONAL", "PREFIX", "ORDER BY", "DESC", "ASC"],
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/[a-zA-Z_]\w*/, { cases: { "@keywords": "keyword", "@default": "identifier" } }],
|
||||
[/[?\$][a-zA-Z_]\w*/, "variable.name"],
|
||||
[/<[^>]+>/, "string.uri"],
|
||||
[/".*?"/, "string"],
|
||||
[/#.*/, "comment"],
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
monacoIns.editor.defineTheme("sparql-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "58a6ff", fontStyle: "bold" },
|
||||
{ token: "variable.name", foreground: "79c0ff" },
|
||||
{ token: "string.uri", foreground: "a5d6ff" },
|
||||
{ token: "string", foreground: "a5d6ff" },
|
||||
{ token: "comment", foreground: "8b949e" }
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#0d1117",
|
||||
"editor.lineHighlightBackground": "#161b22",
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorDidMount(editor: any) {
|
||||
editorRef.current = editor;
|
||||
}
|
||||
|
||||
async function handleRun() {
|
||||
setIsLoading(true);
|
||||
setResult(null);
|
||||
monaco?.editor.setModelMarkers(editorRef.current.getModel(), "sparql", []);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/sparql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
if (data.error_line && monaco && editorRef.current) {
|
||||
monaco.editor.setModelMarkers(editorRef.current.getModel(), "sparql", [
|
||||
{
|
||||
startLineNumber: data.error_line,
|
||||
startColumn: data.error_column || 1,
|
||||
endLineNumber: data.error_line,
|
||||
endColumn: 100,
|
||||
message: data.error,
|
||||
severity: monaco.MarkerSeverity.Error
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 24, boxSizing: "border-box", gap: 24 }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h2 style={{ color: "#ffffff", margin: 0, fontSize: 24 }}>SPARQL Query Engine</h2>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isLoading}
|
||||
style={{ background: "#238636", color: "#fff", border: "none", padding: "8px 24px", borderRadius: 6, fontWeight: 600, cursor: "pointer" }}
|
||||
>
|
||||
{isLoading ? "Running..." : "Run Query"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, overflow: "hidden", border: "1px solid rgba(88,166,255,0.2)" }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sparql"
|
||||
theme="sparql-dark"
|
||||
value={query}
|
||||
onChange={(v) => setQuery(v || "")}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{ minimap: { enabled: false }, fontSize: 14, fontFamily: "monospace" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel" style={{ height: "30%", borderRadius: 12, padding: 16, overflowY: "auto" }}>
|
||||
<h3 style={{ color: "#ffffff", margin: "0 0 16px 0", fontSize: 16 }}>Results</h3>
|
||||
{result?.error ? (
|
||||
<div style={{ color: "#ff7b72" }}>{result.error}</div>
|
||||
) : result?.rows ? (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", color: "#c9d1d9" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
{result.columns.map((c: string) => <th key={c} style={{ textAlign: "left", padding: 8 }}>{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((r: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
{result.columns.map((c: string) => <td key={c} style={{ padding: 8 }}>{r[c]}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e" }}>No results to display. Run a query first.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* ConceptTree.tsx — react-arborist SKOS hierarchy viewer.
|
||||
*
|
||||
* Styled for the Palantir/Glassmorphism dark theme rather than
|
||||
* the default light-mode colours.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Tree } from 'react-arborist';
|
||||
import type { NodeRendererProps } from 'react-arborist';
|
||||
import { ChevronRight, ChevronDown, Folder, FileText } from 'lucide-react';
|
||||
import type { ConceptNode } from './types';
|
||||
|
||||
interface ConceptTreeProps {
|
||||
data: ConceptNode[];
|
||||
onSelectConcept: (concept: ConceptNode) => void;
|
||||
}
|
||||
|
||||
export const ConceptTree: React.FC<ConceptTreeProps> = ({ data, onSelectConcept }) => {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Tree
|
||||
data={data}
|
||||
idAccessor="uri"
|
||||
width="100%"
|
||||
height={600}
|
||||
indent={24}
|
||||
rowHeight={36}
|
||||
childrenAccessor="children"
|
||||
>
|
||||
{(nodeProps: NodeRendererProps<ConceptNode>) => {
|
||||
const { node, style, dragHandle } = nodeProps;
|
||||
const isFolder = node.children && node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
onClick={() => {
|
||||
node.toggle();
|
||||
onSelectConcept(node.data);
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: node.isSelected
|
||||
? 'rgba(88,166,255,0.12)'
|
||||
: 'transparent',
|
||||
userSelect: 'none',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!node.isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'rgba(88,166,255,0.06)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!node.isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 20, display: 'flex', justifyContent: 'center' }}>
|
||||
{isFolder ? (
|
||||
node.isOpen
|
||||
? <ChevronDown size={14} color="#8b949e" />
|
||||
: <ChevronRight size={14} color="#8b949e" />
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<span style={{ marginRight: 8, display: 'flex', alignItems: 'center' }}>
|
||||
{isFolder
|
||||
? <Folder size={14} color="#d2a8ff" />
|
||||
: <FileText size={14} color="#484f58" />
|
||||
}
|
||||
</span>
|
||||
|
||||
<span style={{
|
||||
fontSize: 13, color: '#c9d1d9',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{node.data.pref_label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Tree>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user