mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-04 04:01:07 +00:00
Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7884d71e23 | ||
|
|
ca5f081793 | ||
|
|
6ad1502224 | ||
|
|
b010ba68fa | ||
|
|
7c8dfbd3c0 | ||
|
|
45400c88d3 | ||
|
|
26f6cdf9e5 | ||
|
|
96f88594c2 | ||
|
|
c0d08c46f7 | ||
|
|
5a388d0bcc | ||
|
|
f516aef8fd | ||
|
|
c9a382e676 | ||
|
|
897d950bdc | ||
|
|
dd8fa17db8 | ||
|
|
92801c220e | ||
|
|
738480606c | ||
|
|
f9e0bcf210 | ||
|
|
bb0e9f49e3 | ||
|
|
f95c1612d5 | ||
|
|
8c202a691e | ||
|
|
304b82fbd6 | ||
|
|
8d2dfaa53c | ||
|
|
39aaae778f | ||
|
|
16d628997a | ||
|
|
5e6ad6e87e | ||
|
|
f6198039fa | ||
|
|
983f5301e8 | ||
|
|
5a852169be | ||
|
|
fe6ca7fccb | ||
|
|
66c8431eee | ||
|
|
3e2a0a3f3b | ||
|
|
d22a54353a | ||
|
|
f165679c11 | ||
|
|
17460edca9 | ||
|
|
7e815920ac | ||
|
|
7f93eb7104 | ||
|
|
eec3e8804a | ||
|
|
9cb6073568 | ||
|
|
073c48882c | ||
|
|
be86d1b5db | ||
|
|
6f93f429c4 | ||
|
|
bc683e7a34 | ||
|
|
cda5310949 | ||
|
|
17f88ca600 | ||
|
|
658de23357 | ||
|
|
66e8964d22 | ||
|
|
892ff4b4a7 | ||
|
|
a88300d74f | ||
|
|
390152c78c | ||
|
|
17602812f9 | ||
|
|
523b02083f | ||
|
|
952a4530f5 | ||
|
|
2ce5067aa3 | ||
|
|
d056e47ab7 | ||
|
|
8eafd2d024 | ||
|
|
7ba93f6772 | ||
|
|
d466203761 | ||
|
|
730dea7911 | ||
|
|
47764c3033 | ||
|
|
055d2fd98d | ||
|
|
ce66681715 | ||
|
|
655b553262 | ||
|
|
60bf8ec75e | ||
|
|
a1478af9c4 | ||
|
|
ee4d6a9188 | ||
|
|
2d00257ae5 | ||
|
|
e78ad7f819 | ||
|
|
fdb347fe8a | ||
|
|
1cc2f6b93a | ||
|
|
9e26d96b3c | ||
|
|
7267425eb5 | ||
|
|
d9cf7b0088 | ||
|
|
09666806da | ||
|
|
9daddd8186 | ||
|
|
dc8d7ddb03 | ||
|
|
b34634c8b5 | ||
|
|
ee93c4bbe1 | ||
|
|
e4425818e4 | ||
|
|
7b31304e1e | ||
|
|
ab93ec3e8f | ||
|
|
f2eb3e1608 | ||
|
|
898a660ca7 | ||
|
|
fb1e6a6d6e | ||
|
|
8d0dce13c5 | ||
|
|
61c8435bd7 | ||
|
|
0d7b9ca1df | ||
|
|
aca15d3694 | ||
|
|
670027fd22 | ||
|
|
3ea1283626 | ||
|
|
b313604bde | ||
|
|
5e6df93f64 | ||
|
|
920c0e55d5 | ||
|
|
7de2a2eb5e | ||
|
|
ce60acb294 | ||
|
|
4acdefd4b8 | ||
|
|
a16cb9c468 | ||
|
|
1bdaad9c59 | ||
|
|
db00a3d1ad | ||
|
|
d8b8ae634b | ||
|
|
cdb26aab3b | ||
|
|
f4db4469ba | ||
|
|
98453cab5d | ||
|
|
1fde71768d | ||
|
|
e00bdfe8d4 | ||
|
|
9e31e8d746 | ||
|
|
745927d674 | ||
|
|
dfd7785cc1 | ||
|
|
6e6b190da1 | ||
|
|
e03ba5685c | ||
|
|
1af17f3398 | ||
|
|
c964e11d38 | ||
|
|
8829aa5ce2 | ||
|
|
102274c668 | ||
|
|
38b766298e | ||
|
|
c0f106dc1e | ||
|
|
98a2cf9490 | ||
|
|
9203c2d684 | ||
|
|
719063e781 | ||
|
|
60c00fb5c2 | ||
|
|
1b277dcdd7 | ||
|
|
3065f3c00e |
@@ -0,0 +1 @@
|
||||
# Initialization
|
||||
@@ -0,0 +1 @@
|
||||
# Intialization
|
||||
@@ -0,0 +1 @@
|
||||
# Initialization
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: ./site
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
python-version: '3.11'
|
||||
- run: pip install build
|
||||
- run: python -m build
|
||||
- uses: softprops/action-gh-release@v1
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/*
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Comment PR with Security Results
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
+10
@@ -110,3 +110,13 @@ sample_data/
|
||||
|
||||
# Test Results
|
||||
test_results.txt
|
||||
|
||||
# Frontend workspace artifacts
|
||||
semantica-explorer/
|
||||
node_modules/
|
||||
|
||||
# Frontend build artifacts (generated by Vite — do not track in git)
|
||||
semantica/static/
|
||||
|
||||
# Local graph explorer test datasets
|
||||
demo_out/
|
||||
|
||||
@@ -7,6 +7,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Feature: Graph Workspace declutter + calmer structural exploration** (PR #483 by @ZohaibHassan16, follow-up by @KaifAhmad1):
|
||||
- Added a calmer default presentation for dense graphs: reduced label pressure, stronger inactive-state muting, and tuned zoom-tier visibility to improve readability during overview and structure navigation.
|
||||
- Added display-edge aggregation with raw-edge bundle metadata retention, enabling cleaner visuals while preserving drill-down context for selected edges.
|
||||
- Added grouped community view and neighborhood collapse/expand controls for high-degree local structures in Graph Workspace and Neighborhood panel flows.
|
||||
- Extended graph selection/runtime state with display-state metadata (`groupedViewAvailable`, visible/collapsed neighbor counts, aggregated edge descriptors) for plugin and panel introspection.
|
||||
- Added regression coverage for `resolveDisplayGraph` behavior in `explorer/tests/graphSceneState.display.test.ts`:
|
||||
- parallel-edge aggregation in full view
|
||||
- collapse behavior preserving active-path neighbors
|
||||
- grouped community-node/community-edge projection behavior
|
||||
- Follow-up merge resolution synced the PR branch with `main` after Explorer path migration (`semantica-explorer` -> `explorer`) and preserved PR #483 behavior in conflicted Graph Workspace files.
|
||||
|
||||
- **Fix: DeepSeekProvider now uses OpenAI SDK instead of unmaintained deepseek SDK** (closes #482, PR #482 by @liling, review fixes by @KaifAhmad1):
|
||||
- **Root cause**: The `deepseek` PyPI package has no `deepseek.Client`, causing `AttributeError` on every `DeepSeekProvider` instantiation. The DeepSeek API is OpenAI-compatible, so the `openai` SDK is the correct client.
|
||||
- **`_init_client` rewritten**: Replaced `import deepseek; deepseek.Client(api_key=...)` with `from openai import OpenAI; OpenAI(api_key=..., base_url=self.base_url)`, matching the pattern already used by `NovitaProvider`.
|
||||
- **`self.base_url` added to `__init__`**: Set to `"https://api.deepseek.com/v1"` (with `/v1` suffix required by the OpenAI SDK for correct endpoint resolution). This was missing from the original PR, causing a second `AttributeError` at `_init_client` call time.
|
||||
- **`generate_typed` `verbose_mode` fix**: `verbose_mode` was referenced before assignment inside the instructor path. Added assignment `verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)` at the correct scope.
|
||||
- **`pyproject.toml` updated**: `llm-deepseek` extra now declares `openai>=1.0.0` instead of the defunct `deepseek>=0.1.0`.
|
||||
- **Warning message updated**: `_init_client` ImportError warning now references the `openai` library and `llm-openai` extra.
|
||||
- **Instructor path improved**: Since `self.client` is now an `OpenAI` instance, the `isinstance(self.client, OpenAI)` check in `generate_typed` passes correctly, avoiding a redundant second client construction.
|
||||
- 19 new tests in `tests/semantic_extract/test_pr482_deepseek_openai.py` across five suites: `TestDeepSeekProviderInit` (8 — covers `base_url`, OpenAI instantiation, no `deepseek` import, ImportError handling, `is_available`), `TestDeepSeekProviderGenerate` (5 — `generate`, `generate_structured`, no-client error paths), `TestDeepSeekInstructorPath` (1 — `isinstance` check), `TestVerboseModeAssignment` (4 — no NameError, verbose kwarg, config verbose, no-print default), `TestDeepSeekGenerateTypedInstructorIntegration` (1 — end-to-end instructor path reuses existing client).
|
||||
|
||||
- **Performance: Indexed search for large knowledge graphs** (closes #467, PR #481 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- **Root cause**: The previous `GraphSession.search()` ran a full O(n) scan over all nodes per query, serializing every node's properties to JSON for string matching. On a 118 k-node graph warm queries took 24–471 ms; a session with 500 k nodes was effectively unusable.
|
||||
- **New `semantica/explorer/search_index.py`**: Purpose-built in-memory inverted index with three lookup tiers — exact-term index (full normalized strings), token index (individual words), and prefix index (2–12 character prefixes of every token). A linear secondary-scan fallback (capped at 12 k nodes) handles queries that miss all three tiers. An LRU result cache (128 slots, `OrderedDict`) serves repeat queries at zero cost. Warm query times on the same 118 k-node graph: 24 ms → 0.004 ms (exact), 471 ms → 0.009 ms (ID lookup), 475 ms → 0.002 ms (no-match).
|
||||
- **`IndexedNodeDocument`** frozen dataclass stores per-node primary text (ID, content, curated alias keys: `label`, `name`, `pref_label`, `aliases`, `synonyms`, `display_name`, etc.), secondary text (remaining properties), token set, prefix expansions, confidence, and tags. Primary text prioritizes human-readable fields; secondary text covers the full property bag up to a 48-fragment cap.
|
||||
- **Scoring**: exact ID match → 140, exact term → 120, primary-text substring → 78 + length bonus, token hit → 18, prefix hit → 10, multi-token bonus → 4 per hit. Ties broken deterministically on `(score, exactness, token_hits, node_id)`.
|
||||
- **Mutation sync**: `GraphSession.add_node()`, `add_nodes()`, `add_edges()` and `add_node()` update the index incrementally. `handle_graph_mutation()` integrates with the WebSocket mutation bridge for live updates during reasoning, enrichment, and remote graph changes. `rebuild_search_index()` performs a full O(n) rebuild when needed (session init, merge, reload). `enrich.py` routes node/edge additions through `session.add_node`/`session.add_edge` so reasoning-inferred nodes are indexed immediately.
|
||||
- **Review fixes applied**: replaced `list.sort()` per upsert with `bisect.insort()` (O(log n) vs O(n log n)); replaced `list.remove()` with `bisect.bisect_left` + `pop()` (O(log n) vs O(n)); added `with self._lock` in `handle_graph_mutation()` to prevent index races from the WebSocket thread; removed unnecessary source/target upserts on `add_edge()` (edges don't affect node text); sorted tag values in `_cache_key()` so `["a","b"]` and `["b","a"]` share a cache entry.
|
||||
- **Follow-up fix by @KaifAhmad1 and @ZohaibHassan16**: restored `_ordered_node_ids` maintenance during upserts so `secondary_scan` fallback works for terms that are only present in non-curated properties; added regression test coverage to lock this behavior.
|
||||
- 3 new tests: `test_search_exact_and_prefix` (exact match + prefix match), `test_search_filters_and_cache_stability` (type + confidence filter, identical repeated requests), `test_search_sees_new_nodes_after_mutation` (node added via `session.add_node()` immediately visible in search).
|
||||
- 1 additional regression test: `test_search_secondary_scan_fallback_matches_non_curated_properties` (verifies fallback matching when a query term appears only in non-curated properties).
|
||||
- **Fix: Provenance traversal now includes multi-hop upstream ancestors + edge direction classification** (closes #470, PR #480 by @Sameer6305, review fixes by @KaifAhmad1):
|
||||
- **Bug — upstream ancestors silently excluded**: `_build_provenance()` built a directed `nx.DiGraph` and seeded first-hop neighbors correctly, but the final subgraph extraction called `nx.ego_graph(..., undirected=False)`. With directed traversal, ego-graph expansion only follows outgoing edges from the focus node, so any node that *points into* the focus node (i.e. an upstream ancestor at depth ≥ 2) was invisible. For the chain `Source → Intermediate → node_id`, `Intermediate` appeared at hop 1 but `Source` was silently dropped. Fixed by changing to `undirected=True` — the radius expansion now traverses both incoming and outgoing edges while the underlying `DiGraph` is preserved, so edge source/target semantics remain correct.
|
||||
- **Enhancement — edge direction classification**: `ProvenanceEdge` gains a `direction: str` field. Each edge in the provenance subgraph is classified relative to the focus node: `"upstream"` when `target == node_id` (edge flows into the focus node), `"downstream"` when `source == node_id` (edge flows out), and `"lateral"` for all other edges between non-focus neighbors. This lets consumers distinguish ancestor provenance from descendant impact without re-traversing the graph.
|
||||
- **Enhancement — grouped markdown report**: `_render_markdown()` now groups lineage edges under separate `## Upstream`, `## Downstream`, and `## Lateral` sections instead of a flat `## Lineage Edges` list. Empty sections are omitted. This improves readability of exported provenance reports.
|
||||
- **Schema consolidation**: `ProvenanceNode`, `ProvenanceEdge`, and `ProvenanceResponse` moved from inline definitions in `routes/provenance.py` to the shared `semantica/explorer/schemas.py`, matching the convention used by all other Explorer routes. `ProvenanceNode.parent_id` is now `Optional[str] = None`.
|
||||
- **Merge conflicts resolved**: Resolved all conflict markers in `provenance.py`, `app.py`, and `.gitignore`; restored the complete router import set in `app.py` (`graph`, `sparql`, `temporal`, `vocabulary`) that the conflict had dropped.
|
||||
- 2 new tests in `tests/explorer/test_provenance_route.py`: `test_build_provenance_direction_classification_chain` (asserts `Source` and `Intermediate` both appear for `Source → Intermediate → node_id`; verifies `Intermediate → node_id` classified as `"upstream"`) and `test_render_markdown_groups_edges_by_direction` (asserts grouped section headings and correct edge lines in output).
|
||||
|
||||
- **Fix: `OWLExporter._export_owl_turtle` invalid Turtle syntax and silent data-property omission** (closes #478 by @KaifAhmad1):
|
||||
- **Bug 1 — Invalid Turtle syntax**: `_export_owl_turtle` unconditionally wrote `rdfs:label` with a closing period (`.`), then appended `rdfs:subClassOf`, `rdfs:domain`, and `rdfs:range` triples after the closed block. Any RDF parser would reject the output. Fixed by introducing `_ttl_block(subject_uri, rdf_type, predicates)` — all predicate-object pairs for a subject are accumulated first, then joined with ` ;\n ` and terminated with a single ` .`, producing valid Turtle in all cases.
|
||||
- **Bug 2 — Data properties silently dropped**: `_export_owl_turtle` had loops for `classes` and `object_properties` but no loop for `data_properties`, so all `owl:DatatypeProperty` declarations were silently omitted. Added the missing loop, mirroring the existing object-property loop.
|
||||
- **String escaping**: User-provided strings (`name`, `description`, `comment`, version) were embedded directly into Turtle string literals without escaping. A class named `John"s Class` or a comment containing a backslash or newline produced unparseable output. Added `_escape_ttl_str()` static method (escapes `"`, `\`, `\n`, `\r`, `\t`) applied at every `rdfs:label`, `rdfs:comment`, and `owl:versionInfo` site.
|
||||
- **Null-check consistency**: All optional field reads now use `x = prop.get("field"); if x:` uniformly — eliminates the mixed pattern of `.get()` guards followed by direct `[]` access.
|
||||
- 43 tests added in `tests/export/test_owl_exporter.py` across five suites: `TestTurtleSyntaxValidity` (5), `TestDataPropertiesInTurtle` (8), `TestTurtleHeader` (4), `TestTurtleStringEscaping` (16), `TestNullFieldHandling` (7), plus `TestObjectPropertyListDomainRange` (2) and `TestEquivalentClass` (1).
|
||||
|
||||
- **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (0–1 hops), `"near"` (2–3), `"mid-range"` (4–6), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops).
|
||||
|
||||
- **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`).
|
||||
|
||||
- **Enhancement: Native `KnowledgeGraph` type support in `KGVisualizer`** (PR `kg` by @KaifAhmad1, closes #471): Added `semantica/kg/knowledge_graph.py` — a formal `KnowledgeGraph` dataclass (`entities`, `relationships`, `metadata`) that is now the canonical in-memory type produced and consumed by the Semantica KG pipeline. Exported from `semantica.kg`. `KGVisualizer` gains `_convert_knowledge_graph()` — an authoritative, non-mutating conversion path from `KnowledgeGraph` to the internal dict format — and `_normalize_graph()` now routes `isinstance(graph, KnowledgeGraph)` through it as an explicit fast-path before duck-typing. All five public entry points (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) accept `KnowledgeGraph` directly; no manual conversion required. All existing callers passing dicts or duck-typed objects are unaffected. 15 new tests in `TestFormalKnowledgeGraphType` (conversion shape, non-mutation, determinism, routing, all five entry points, import availability).
|
||||
- **Fix: `KGVisualizer` now accepts `KnowledgeGraph` objects in all `visualize_*` methods** (PR `visualization` by @KaifAhmad1, closes #458): All five public methods (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) previously called `graph.get("entities", [])`, silently producing no output when passed a non-dict object. Added `_normalize_graph()` which duck-types the input — dicts pass through unchanged; any object exposing `.entities` / `.relationships` attributes (e.g. the result of `GraphBuilder.build()`) is converted to the canonical dict form; anything else raises a clear `ProcessingError` naming the offending type. 21 tests added in `tests/visualization/test_kg_visualizer_normalize_graph.py`.
|
||||
|
||||
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
|
||||
|
||||
**Critical**
|
||||
- **Eval injection eliminated** (`semantica/parse/media_parser.py`): Replaced `eval(stream.get("r_frame_rate", ...))` — which executed arbitrary Python from ffprobe JSON output — with a `_safe_parse_fps()` helper using `fractions.Fraction`. No code execution possible regardless of ffprobe output content. (CWE-95)
|
||||
- **Unsafe pickle deserialization replaced** (`semantica/context/agent_memory.py`): `AgentMemory.save()` / `load()` previously used `pickle.dump` / `pickle.load`, allowing RCE if an attacker could write the `.pkl` file. Replaced with `json.dump` / `json.load`. `MemoryItem.to_dict()` / `MemoryItem.from_dict()` added for safe round-trip serialization — `timestamp` via `isoformat()`, `embedding` dropped (not JSON-safe, regenerated on demand). Legacy `.pkl` files are detected and refused with a migration message. (CWE-502)
|
||||
|
||||
**High**
|
||||
- **SQL injection hardened** (`semantica/ingest/snowflake_ingestor.py`): `WHERE`, `ORDER BY`, `LIMIT`, and `OFFSET` were f-string interpolated directly into Snowflake queries. `LIMIT` / `OFFSET` now use parameterized `%s` placeholders; `ORDER BY` is validated against a strict `^[A-Za-z_][A-Za-z0-9_]*(\s+(ASC|DESC))?` regex; `WHERE` clauses containing semicolons are rejected before execution. (CWE-89)
|
||||
- **XXE protection added for RDF/XML parsing** (`semantica/explorer/utils/rdf_parser.py`): `rdflib.Graph().parse()` on XML-based RDF formats had no external-entity restrictions. Added `_safe_parse_rdf()` wrapper that calls `defusedxml.defuse_stdlib()` before parsing, neutralising Billion Laughs and local file-read XXE attacks. Falls back gracefully with a `UserWarning` if `defusedxml` is not installed. (CWE-611)
|
||||
- **Security headers and CORS added to main server** (`semantica/server.py`): No CORS policy, no response security headers, and raw `Exception` details were returned to clients. Added `CORSMiddleware` (origins from `SEMANTICA_CORS_ORIGINS` env var, defaults to `localhost` only); `_SecurityHeadersMiddleware` emitting `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, and HSTS (HTTPS only) on every response; global error handler that logs internally and returns a generic `500`. (CWE-346, CWE-200)
|
||||
- **CORS and WebSocket hardened in Explorer app** (`semantica/explorer/app.py`): `allow_methods=["*"]` and `allow_headers=["*"]` narrowed to `GET, POST, DELETE, OPTIONS` and `Content-Type, Authorization` only. `KeyError` / `ValueError` exception handlers now log the real message server-side and return generic text to clients. WebSocket messages larger than 64 KB trigger close with code `1009` (Message Too Big), preventing memory exhaustion via large frame injection. (CWE-346, CWE-400)
|
||||
|
||||
**Medium**
|
||||
- **Algorithm parameter validated by enum** (`semantica/explorer/routes/graph.py`): The `algorithm` query parameter previously accepted any string; unknown values silently fell back to BFS. Replaced with `_PathAlgorithm(str, Enum)` — FastAPI now returns `422 Unprocessable Entity` for any value other than `bfs` or `dijkstra`. (CWE-20)
|
||||
- **RDF upload extension allowlist** (`semantica/explorer/routes/vocabulary.py`): No file extension check was performed before reading RDF uploads. Extension is now validated against `{".ttl", ".rdf", ".owl", ".xml", ".jsonld", ".json-ld", ".json"}` before any content is read. (CWE-434)
|
||||
- **Prompt injection mitigated** (`semantica/semantic_extract/llm_extraction.py`): User-supplied text and entity/relation labels were embedded directly into LLM prompts via f-string interpolation — a crafted input like `"\n\nIgnore all above instructions..."` could override system instructions. All user-supplied content is now passed through `json.dumps()` before embedding, neutralising newlines, quotes, and instruction-override attempts. (CWE-1336)
|
||||
- **Dynamic `__import__()` removed** (`semantica/pipeline/pipeline_validator.py`): `__import__("collections").Counter(...)` replaced with a proper `from collections import Counter` module-level import. (CWE-95)
|
||||
- **ReDoS eliminated** (`semantica/explorer/routes/enrich.py`): `re.split(r"\s+AND\s+", antecedent_text, re.IGNORECASE)` on user-supplied rule strings could exhibit polynomial backtracking. Fixed by normalising whitespace first with `" ".join(text.split())` (no regex) then splitting on the literal `" AND "`. Closes CodeQL alert #12. (CWE-1333)
|
||||
- **Path traversal blocked** (`semantica/server.py`): SPA catch-all route used `STATIC_DIR / full_path` without validation. Added `Path.resolve()` + `relative_to()` check that returns `400 Bad Request` for any path that escapes `STATIC_DIR`. Closes CodeQL alerts #13 and #14. (CWE-22)
|
||||
|
||||
**Low**
|
||||
- **SPARQL result cap and timeout** (`semantica/explorer/routes/sparql.py`): SPARQL queries ran to completion with no row limit or timeout; expensive queries could exhaust memory or block indefinitely. Results are now capped at 5 000 rows; `asyncio.wait_for(..., timeout=30)` abandons the await after 30 seconds and returns a structured error response. A module-level `asyncio.Semaphore(4)` caps concurrent in-flight `graph.query` calls so that timed-out threads (which continue running in the pool) cannot crowd out other requests by exhausting executor workers. `SparqlResponse` gains a `truncated: bool` field so callers can detect a capped result set. (CWE-400)
|
||||
- **Import upload size limit and extension allowlist** (`semantica/explorer/routes/export_import.py`): No file size or type checks were enforced before reading import uploads. Extension is now validated against `{".json", ".csv"}` (the formats the handler actually parses — allowlist trimmed to match implementation); a hard 50 MB cap is enforced before content is read. (CWE-434)
|
||||
|
||||
**CodeQL / scanning infrastructure**
|
||||
- Added `.github/codeql/codeql-config.yml` with `paths-ignore` for `cookbook/**/*.html` and `cookbook/**/*.js`. Notebook-exported HTML files embed entire minified third-party bundles (Plotly + MapLibre GL JS v4.7.1) that triggered false-positive JS alerts #15–#18. The Advanced Setup workflow now references this config via `config-file:`. Closes CodeQL alerts #15, #16, #17, #18.
|
||||
- Removed blanket rule-ID auto-dismiss job from `.github/workflows/codeql.yml`. The previous job dismissed every open alert whose `rule.id` matched a fixed list on each `main` push — this would silently suppress any future real vulnerability of the same type. Replaced with a commented template for pinning specific alert numbers when manual dismissal is genuinely required.
|
||||
|
||||
- **Fix: Knowledge Explorer — blockers and security hardening** (PR #420 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- **Dockerfile**: Renamed `DockerFile` → `Dockerfile` (case-sensitive filename caused Docker build failures on Linux CI). Fixed `CMD` module path from the non-existent `semantica.server:app` to `semantica.explorer.app:app`, which caused the Docker image to crash on startup. Added `app = create_app()` at module level in `semantica/explorer/app.py` so uvicorn can reference the ASGI app instance directly.
|
||||
- **CORS hardening**: Changed `EXPLORER_CORS_ORIGINS` default from `"*"` to `"http://localhost:5173,http://127.0.0.1:5173"`. Any deployment that does not explicitly set the env var no longer exposes the API to all origins. The env var override continues to work as before.
|
||||
- **`get_ws_manager()` guard** (`semantica/explorer/dependencies.py`): `get_ws_manager()` now raises HTTP 503 if `app.state.ws_manager` is absent, matching the existing `get_session()` guard. Previously raised an unhandled `AttributeError` during testing or if the lifespan had not completed.
|
||||
- **SPARQL read-only enforcement** (`semantica/explorer/routes/sparql.py`): Added `_is_read_only_query()` regex guard — rejects any query whose first keyword is not `SELECT`, `ASK`, `CONSTRUCT`, or `DESCRIBE`. `INSERT`, `DELETE`, `UPDATE`, `LOAD`, and `DROP` queries now return a structured `SparqlResponse` error instead of being executed against the rdflib projection.
|
||||
- **Vocabulary import size limit** (`semantica/explorer/routes/vocabulary.py`): Added 10 MB upload cap for both file and raw-text payloads — returns HTTP 413 with a human-readable message before calling `parse_skos_file()`. Prevents memory exhaustion from oversized RDF uploads.
|
||||
- **JSON-LD format auto-detection** (`semantica/explorer/routes/vocabulary.py`): Import route now detects `.jsonld`, `.json-ld`, and `.json` file extensions and passes `"json-ld"` to `parse_skos_file()`. Previously these extensions fell through to `"turtle"` and failed silently despite JSON-LD being listed as a supported format.
|
||||
- **Annotation O(1) lookup** (`semantica/explorer/routes/annotations.py`, `semantica/explorer/session.py`): `create_annotation` previously called `get_annotations()` and scanned the full list to find the just-created annotation (O(N)). Added `GraphSession.get_annotation(annotation_id)` — O(1) dict lookup — and updated the route to use it directly.
|
||||
- **Self-loop guard in `batchMergeEdges`** (`semantica-explorer/src/store/graphStore.ts`): Added `if (source === target) continue` guard at the start of the loop. The Graphology instance is initialised with `allowSelfLoops: false`; a self-loop edge from reasoning inferences or provenance cycles previously caused an uncaught Graphology error that silently broke graph loading.
|
||||
- **Static build artifacts removed from git** (`.gitignore`): Added `semantica/static/` to `.gitignore` and removed all pre-built Vite bundles from version control. The Docker multi-stage build already rebuilds the frontend from source; committing minified bundles bloated repository history and caused merge conflicts on every frontend change.
|
||||
|
||||
- **Fix: TripletStore.store() IRI resolution regressions** (PR #447 follow-up by @KaifAhmad1):
|
||||
- Fixed `AttributeError` crash when entity or relationship IDs are non-string types (e.g. integers emitted by `GraphBuilder`). `_resolve_iri()` previously called `.startswith()` directly on the raw ID; it now coerces any value to `str()` at entry, restoring the implicit stringification that the old f-string URN minting provided.
|
||||
- Fixed W3C vocabulary prefixes (`owl:Thing`, `xsd:date`, `rdfs:Literal`, `skos:Concept`, etc.) being incorrectly re-namespaced under the ontology `base_uri` (e.g. `https://example.com/owl:Thing`) when `base_uri` was present. `_resolve_iri()` now consults a known-prefix expansion table (`xsd`, `rdf`, `rdfs`, `owl`, `skos`, `semantica`) before applying `base_uri`, matching the same prefix map already used in `BlazegraphStore`. Standard vocabulary IRIs are always expanded to their canonical W3C forms regardless of what `base_uri` is set to.
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
FROM node:25-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.14-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"]
|
||||
@@ -14,6 +14,7 @@
|
||||
[](https://github.com/Hawksight-AI/semantica/actions)
|
||||
[](https://discord.gg/sV34vps5hH)
|
||||
[](https://x.com/BuildSemantica)
|
||||
[](https://openclaw.ai)
|
||||
|
||||
### ⭐ Give us a Star · 🍴 Fork us · 💬 Join our Discord · 🐦 Follow on X
|
||||
|
||||
@@ -45,7 +46,7 @@ Semantica is the **context and intelligence layer** you add on top of your exist
|
||||
- ✅ **Reasoning Engines** — forward chaining, Rete networks, deductive, abductive, and SPARQL. Explainable paths, not black boxes.
|
||||
- ✅ **Quality & Deduplication** — conflict detection, entity resolution, and pipeline validation built in.
|
||||
|
||||
> Works alongside LangChain, LlamaIndex, AutoGen, CrewAI, and any LLM — Semantica is the **accountability layer** on top, not a replacement.
|
||||
> Works alongside **Agno** and any LLM — Semantica is the **accountability layer** on top, not a replacement. LangChain, LangGraph, CrewAI, and more coming soon.
|
||||
|
||||
```bash
|
||||
pip install semantica
|
||||
@@ -53,17 +54,314 @@ pip install semantica
|
||||
|
||||
---
|
||||
|
||||
## Plugins (Claude, Cursor, Codex)
|
||||
## 🔌 Works With Every AI Tool
|
||||
|
||||
Semantica includes a cross-platform plugin bundle under `plugins/` for community use:
|
||||
Semantica ships **native plugin bundles** for Claude Code, Cursor, and Codex, an **MCP server** (`python -m semantica.mcp_server`) for Windsurf, Cline, Continue, VS Code, Claude Desktop, and OpenClaw, and a **REST API** (FastAPI, port 8000) for any other tool.
|
||||
|
||||
- 17 domain skills (context graphs, decision intelligence, explainability, reasoning, provenance, ontology, temporal, visualization)
|
||||
- Specialized agents (`decision-advisor`, `explainability`, `kg-assistant`)
|
||||
- Hook configuration and platform-specific manifests for Claude, Cursor, and Codex
|
||||
<table>
|
||||
|
||||
See the community setup guide:
|
||||
<!-- ── Native Plugin Bundle ──────────────────────────────────────────── -->
|
||||
<tr>
|
||||
<th colspan="3" align="left">🔌 Native Plugin Bundle</th>
|
||||
<th colspan="5" align="left">⚡ MCP Server + Plugin</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://claude.com/product/claude-code"><img src="https://github.com/anthropics.png?size=120" alt="Claude Code" width="48" height="48" /></a><br/>
|
||||
<strong>Claude Code</strong><br/>
|
||||
<sub>17 skills · 3 agents · hooks</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://cursor.com"><img src="https://www.freelogovectors.net/wp-content/uploads/2025/06/cursor-logo-freelogovectors.net_.png" alt="Cursor" width="48" height="48" /></a><br/>
|
||||
<strong>Cursor</strong><br/>
|
||||
<sub>17 skills · 3 agents</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/openai/codex"><img src="https://github.com/openai.png?size=120" alt="Codex CLI" width="48" height="48" /></a><br/>
|
||||
<strong>Codex CLI</strong><br/>
|
||||
<sub>17 skills · 3 agents</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://windsurf.com"><img src="https://exafunction.github.io/public/brand/windsurf-black-symbol.svg" alt="Windsurf" width="48" height="48" /></a><br/>
|
||||
<strong>Windsurf</strong><br/>
|
||||
<sub><a href="plugins/.windsurf-plugin/">plugin</a></sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/cline/cline"><img src="https://github.com/cline.png?size=120" alt="Cline" width="48" height="48" /></a><br/>
|
||||
<strong>Cline</strong><br/>
|
||||
<sub><a href="plugins/.cline-plugin/">plugin</a></sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/continuedev/continue"><img src="https://github.com/continuedev.png?size=120" alt="Continue" width="48" height="48" /></a><br/>
|
||||
<strong>Continue</strong><br/>
|
||||
<sub><a href="plugins/.continue-plugin/">plugin</a></sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/microsoft/vscode"><img src="https://github.com/microsoft.png?size=120" alt="VS Code" width="48" height="48" /></a><br/>
|
||||
<strong>VS Code</strong><br/>
|
||||
<sub><a href="plugins/.vscode-plugin/">plugin</a></sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="integrations/openclaw/"><img src="https://github.com/openclaw.png?size=120" alt="OpenClaw" width="48" height="48" /></a><br/>
|
||||
<strong>OpenClaw</strong><br/>
|
||||
<sub>MCP + <a href="integrations/openclaw/">plugin</a></sub>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
- [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
|
||||
<!-- ── MCP Server only · REST API ───────────────────────────────────── -->
|
||||
<tr>
|
||||
<th colspan="1" align="left">☁️ MCP Server</th>
|
||||
<th colspan="7" align="left">🌐 REST API</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://claude.ai/download"><img src="https://github.com/anthropics.png?size=120" alt="Claude Desktop" width="48" height="48" /></a><br/>
|
||||
<strong>Claude Desktop</strong><br/>
|
||||
<sub>MCP server</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/features/copilot"><img src="https://github.com/github.png?size=120" alt="GitHub Copilot" width="48" height="48" /></a><br/>
|
||||
<strong>GitHub Copilot</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/RooCodeInc/Roo-Code"><img src="https://github.com/RooCodeInc.png?size=120" alt="Roo Code" width="48" height="48" /></a><br/>
|
||||
<strong>Roo Code</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/block/goose"><img src="https://github.com/block.png?size=120" alt="Goose" width="48" height="48" /></a><br/>
|
||||
<strong>Goose</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/Kilo-Org/kilocode"><img src="https://github.com/Kilo-Org.png?size=120" alt="Kilo Code" width="48" height="48" /></a><br/>
|
||||
<strong>Kilo Code</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/Aider-AI/aider"><img src="https://github.com/Aider-AI.png?size=120" alt="Aider" width="48" height="48" /></a><br/>
|
||||
<strong>Aider</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/aws/amazon-q-developer-cli"><img src="https://github.com/aws.png?size=120" alt="Amazon Q" width="48" height="48" /></a><br/>
|
||||
<strong>Amazon Q</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://zed.dev"><img src="https://github.com/zed-industries.png?size=120" alt="Zed" width="48" height="48" /></a><br/>
|
||||
<strong>Zed</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- ── Any tool via REST ─────────────────────────────────────────────── -->
|
||||
<tr>
|
||||
<th colspan="8" align="left">🔧 Any Tool</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="8">
|
||||
<img src="https://img.shields.io/badge/109-endpoints-1f6feb?style=flat-square" alt="REST API" width="48" /><br/>
|
||||
<strong>Any agent</strong><br/>
|
||||
<sub>109 REST endpoints · FastAPI · port 8000</sub>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
### Agentic Frameworks
|
||||
|
||||
Semantica integrates with **Agno** today. Coming soon: LangChain, LangGraph, CrewAI, LlamaIndex, AutoGen, OpenAI Agents SDK, Google ADK, and more.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th colspan="8" align="left">✅ Supported</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/agno-agi/agno"><img src="https://github.com/agno-agi.png?size=120" alt="Agno" width="48" height="48" /></a><br/>
|
||||
<strong>Agno</strong><br/>
|
||||
<sub>First-class · <code>pip install semantica[agno]</code></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="8" align="left">🔜 Coming Soon</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
|
||||
<strong>LangChain</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/langchain-ai/langgraph"><img src="https://github.com/langchain-ai.png?size=120" alt="LangGraph" width="48" height="48" /></a><br/>
|
||||
<strong>LangGraph</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/crewAIInc/crewAI"><img src="https://github.com/crewAIInc.png?size=120" alt="CrewAI" width="48" height="48" /></a><br/>
|
||||
<strong>CrewAI</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/run-llama/llama_index"><img src="https://github.com/run-llama.png?size=120" alt="LlamaIndex" width="48" height="48" /></a><br/>
|
||||
<strong>LlamaIndex</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/microsoft/autogen"><img src="https://github.com/microsoft.png?size=120" alt="AutoGen" width="48" height="48" /></a><br/>
|
||||
<strong>AutoGen</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/openai/openai-agents-python"><img src="https://github.com/openai.png?size=120" alt="OpenAI Agents SDK" width="48" height="48" /></a><br/>
|
||||
<strong>OpenAI Agents</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/google/adk-python"><img src="https://github.com/google.png?size=120" alt="Google ADK" width="48" height="48" /></a><br/>
|
||||
<strong>Google ADK</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> **Agno — First-Class Integration** · `pip install semantica[agno]`
|
||||
>
|
||||
> Five integration modules live in [`integrations/agno/`](integrations/agno/):
|
||||
>
|
||||
> | Module | Class | What it does |
|
||||
> |---|---|---|
|
||||
> | `context_store.py` | `AgnoContextStore` | Graph-backed agent memory — store and retrieve structured context |
|
||||
> | `knowledge_graph.py` | `AgnoKnowledgeGraph` | Implements Agno's `AgentKnowledge` protocol; full extraction pipeline |
|
||||
> | `decision_kit.py` | `AgnoDecisionKit` | 6 decision-intelligence tools for Agno agents |
|
||||
> | `kg_toolkit.py` | `AgnoKGToolkit` | 7 KG pipeline tools (build, query, enrich, export) |
|
||||
> | `shared_context.py` | `AgnoSharedContext` | Shared context graph for multi-agent team coordination |
|
||||
|
||||
### Plugin Bundles (Claude Code · Cursor · Codex)
|
||||
|
||||
Native plugin bundles live under [`plugins/`](plugins/). Each directory contains a `plugin.json`, `marketplace.json`, and `README.md`.
|
||||
|
||||
| Bundle | Directory | Tools |
|
||||
|---|---|---|
|
||||
| Claude Code | [`plugins/.claude-plugin/`](plugins/.claude-plugin/) | 17 skills · 3 agents · hooks |
|
||||
| Cursor | [`plugins/.cursor-plugin/`](plugins/.cursor-plugin/) | 17 skills · 3 agents · hooks |
|
||||
| Codex CLI | [`plugins/.codex-plugin/`](plugins/.codex-plugin/) | 17 skills · 3 agents |
|
||||
| Windsurf | [`plugins/.windsurf-plugin/`](plugins/.windsurf-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
| Cline | [`plugins/.cline-plugin/`](plugins/.cline-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
| Continue | [`plugins/.continue-plugin/`](plugins/.continue-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
| VS Code | [`plugins/.vscode-plugin/`](plugins/.vscode-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
| OpenClaw | [`plugins/.openclaw-plugin/`](plugins/.openclaw-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
|
||||
**17 domain skills:**
|
||||
|
||||
| Skill | What it does |
|
||||
|---|---|
|
||||
| `extract` | Full semantic extraction pipeline: NER, relations, events, coreference, triplets |
|
||||
| `ingest` | Data ingestion from files, databases, APIs, streams, and MCP servers |
|
||||
| `query` | SPARQL, Cypher, keyword search, structured graph patterns |
|
||||
| `ontology` | Schema management, concepts, relationships, alignments |
|
||||
| `validate` | Pipeline, extraction, schema, and ontology validation |
|
||||
| `deduplicate` | Duplicate detection and entity merging with fuzzy matching |
|
||||
| `embed` | Node2Vec embeddings, similarity scoring, link prediction |
|
||||
| `reason` | Deductive, abductive, Datalog, SPARQL, and Rete reasoning engines |
|
||||
| `decision` | Record, query, and analyze decisions; find precedents; causal analysis |
|
||||
| `causal` | Cause-effect chains, interventions, counterfactuals, causal influence |
|
||||
| `temporal` | Point-in-time queries, snapshots, timelines, temporal causal analysis |
|
||||
| `provenance` | Data lineage, source attribution, audit trails |
|
||||
| `policy` | Policy definition, enforcement, compliance checks, access control |
|
||||
| `explain` | Decision logic transparency, causal context, audit-ready explanations |
|
||||
| `export` | Multi-format export: JSON, RDF, Parquet, CSV, GraphML |
|
||||
| `change` | Graph change tracking, diffs, temporal updates, impact analysis |
|
||||
| `visualize` | Topology, centrality, communities, paths, embeddings, decision graphs |
|
||||
|
||||
**3 specialized agents:**
|
||||
|
||||
| Agent | Role |
|
||||
|---|---|
|
||||
| `kg-assistant` | General-purpose KG-aware assistant — knows all APIs and method signatures |
|
||||
| `decision-advisor` | Decision intelligence specialist: causal reasoning, precedents, policy violations |
|
||||
| `explainability` | Reasoning transparency specialist — generates audit-ready explanation reports |
|
||||
|
||||
**Hooks** (`plugins/hooks/hooks.json`) — `PreToolUse` / `PostToolUse` matchers for syntax validation and automated warnings.
|
||||
|
||||
→ [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
|
||||
|
||||
### MCP Server (expose Semantica to any MCP-aware tool)
|
||||
|
||||
Semantica ships a full **MCP server** (`semantica/mcp_server.py`) — run it once and any MCP-compatible tool connects automatically:
|
||||
|
||||
```bash
|
||||
python -m semantica.mcp_server
|
||||
```
|
||||
|
||||
Add to your tool's config (Claude Desktop, Windsurf, Cline, Continue, VS Code, Roo Code):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "semantica.mcp_server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**12 tools exposed:** `extract_entities`, `extract_relations`, `record_decision`, `query_decisions`, `find_precedents`, `get_causal_chain`, `add_entity`, `add_relationship`, `run_reasoning`, `get_graph_analytics`, `export_graph`, `get_graph_summary`
|
||||
|
||||
**3 resources:** `semantica://graph/summary`, `semantica://decisions/list`, `semantica://schema/info`
|
||||
|
||||
See [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md) for per-tool config snippets.
|
||||
|
||||
### MCP Client (Ingest from MCP Servers)
|
||||
|
||||
Semantica also includes an **MCP client** (`semantica/ingest/mcp_client.py`) that lets you pull data from any Python/FastMCP server into a knowledge graph:
|
||||
|
||||
```python
|
||||
from semantica.ingest import MCPClient
|
||||
|
||||
client = MCPClient("http://your-mcp-server:8080")
|
||||
resources = client.list_resources() # discover available resources
|
||||
data = client.read_resource("resource://your-data")
|
||||
```
|
||||
|
||||
Supported connection schemes: `http://`, `https://`, `mcp://`, `sse://` · JSON-RPC · auth support · dynamic capability discovery.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Semantica Knowledge Explorer
|
||||
|
||||
A real-time visual interface for exploring every dimension of your knowledge graph — built into the repo under [`explorer/`](explorer/).
|
||||
|
||||
| Workspace | What you can do |
|
||||
|---|---|
|
||||
| **Knowledge Graph** | Pan, zoom, and inspect a live Sigma.js graph canvas with ForceAtlas2 layout |
|
||||
| **Timeline** | Scrub through temporal events and watch the graph evolve |
|
||||
| **Decisions** | Browse the causal chain behind every recorded decision with outcome badges |
|
||||
| **Registry** | Live audit log of every graph mutation — add-node, add-edge, merge, delete |
|
||||
| **Entity Resolution** | Review and merge duplicate entities detected by the deduplication engine |
|
||||
| **KG Overview** | Aggregate stats, community breakdown, centrality heatmap |
|
||||
| **Ontology** | SKOS/OWL vocabulary hierarchy and auto-generated schema summary |
|
||||
|
||||
### Run locally
|
||||
|
||||
```bash
|
||||
# 1. Start the Semantica backend (port 8000)
|
||||
python -m semantica.server
|
||||
|
||||
# 2. In a second terminal
|
||||
cd explorer
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open **http://localhost:5173** — the Explorer connects automatically. All `/api` and `/ws` traffic is proxied to `127.0.0.1:8000` by Vite, so no CORS configuration is needed.
|
||||
|
||||
> **Requirements:** Node 18+ · Python 3.8+ · npm 9+
|
||||
|
||||
For the full setup guide, troubleshooting, and production build instructions see [`explorer/README.md`](explorer/README.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -347,6 +645,7 @@ Semantic memory with hybrid search and metadata filtering.
|
||||
| `semantica.change_management` | Version storage, change tracking, checksums, audit trails, compliance support for KGs and ontologies |
|
||||
| `semantica.triplet_store` | RDF triplet store integration — Blazegraph, Jena, RDF4J; SPARQL queries and bulk loading |
|
||||
| `semantica.visualization` | Interactive and static visualization of KGs, ontologies, embeddings, analytics, and temporal graphs |
|
||||
| [`explorer/`](explorer/) | **Semantica Knowledge Explorer** — React 19 + Sigma.js UI: graph canvas, decision viewer, causal chains, entity resolution, ontology browser, and registry audit log |
|
||||
| `semantica.seed` | Seed data management for initial KG construction from CSV, JSON, databases, and APIs |
|
||||
| `semantica.core` | Framework orchestration, configuration management, knowledge base construction, plugin system |
|
||||
| `semantica.llms` | LLM provider integrations — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM |
|
||||
@@ -643,16 +942,13 @@ if result.valid:
|
||||
- **`semantica.triplet_store`** — Blazegraph, Jena, RDF4J; SPARQL, bulk loading, SKOS helpers
|
||||
- **`semantica.visualization`** — KG, ontology, embedding, and temporal graph visualization
|
||||
- **`semantica.llms`** — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Integrations
|
||||
- **[`explorer/`](explorer/)** — **Semantica Knowledge Explorer** — browser UI for live graph inspection, decisions, entity resolution, and ontology browsing (`npm run dev` in `explorer/`)
|
||||
|
||||
### Graph Databases
|
||||
- **AWS Neptune** — Amazon Neptune with IAM authentication
|
||||
- **Neo4j** — Cypher queries via `semantica.graph_store`
|
||||
- **FalkorDB** — native support; `DecisionQuery` and `CausalChainAnalyzer` work directly with FalkorDB row/header shapes
|
||||
- **Apache AGE** — PostgreSQL + openCypher via SQL
|
||||
- **FalkorDB** — native support for decision queries and causal analysis
|
||||
- **AWS Neptune** — Amazon Neptune with IAM authentication
|
||||
|
||||
### Vector Databases
|
||||
- **FAISS** — built-in, zero extra dependencies
|
||||
@@ -668,22 +964,15 @@ if result.valid:
|
||||
- **Databases** — SQL via `DBIngestor`
|
||||
- **Snowflake** — table/query ingestion, pagination, password/key-pair/OAuth/SSO auth · `pip install semantica[db-snowflake]`
|
||||
- **Docling** — advanced table and layout extraction (PDF, DOCX, PPTX, XLSX)
|
||||
- **Email** — inbox ingestion via `EmailIngestor`
|
||||
- **Repositories** — Git repo ingestion for code graph construction
|
||||
|
||||
### LLM Providers
|
||||
- **LiteLLM** — 100+ models: OpenAI, Anthropic, Cohere, Mistral, Ollama, Azure, AWS Bedrock, and more
|
||||
- **Novita AI** — OpenAI-compatible (`deepseek/deepseek-v3.2` and more) · set `NOVITA_API_KEY`
|
||||
|
||||
### Agentic Frameworks
|
||||
Semantica complements — not replaces — LangChain, LlamaIndex, AutoGen, CrewAI, Google ADK, and more.
|
||||
|
||||
> **Agno — First-Class Integration** · `pip install semantica[agno]`
|
||||
>
|
||||
> Five ready-to-use Agno components:
|
||||
> - `AgnoContextStore` — graph-backed agent memory
|
||||
> - `AgnoKnowledgeGraph` — multi-hop GraphRAG knowledge base
|
||||
> - `AgnoDecisionKit` — 6 decision-intelligence tools
|
||||
> - `AgnoKGToolkit` — 7 KG pipeline tools
|
||||
> - `AgnoSharedContext` — shared context graph for multi-agent teams
|
||||
- **Groq** — ultra-low latency inference · set `GROQ_API_KEY`
|
||||
- **HuggingFace** — local and hosted models via `HuggingFaceProvider`
|
||||
- **Ollama** — local models including remote server support
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# Semantica v0.3.0 — Release Notes
|
||||
|
||||
**Released:** 2026-03-10
|
||||
**PyPI:** `pip install semantica`
|
||||
**Tag:** [v0.3.0](https://github.com/Hawksight-AI/semantica/releases/tag/v0.3.0)
|
||||
**Classification:** Production/Stable
|
||||
|
||||
> First stable, full public release of Semantica. Covers everything shipped across three release stages: 0.3.0-alpha (2026-02-19), 0.3.0-beta (2026-03-07), and 0.3.0 stable (2026-03-10).
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
| Contributor | Role |
|
||||
|------------|------|
|
||||
| [@KaifAhmad1](https://github.com/KaifAhmad1) | Lead maintainer — context graph, decision intelligence, KG algorithms, semantic extraction, pipeline, provenance, bug fixes, release management |
|
||||
| [@ZohaibHassan16](https://github.com/ZohaibHassan16) | Deduplication v2 suite (candidate generation, two-stage scoring, semantic dedup), incremental/delta processing, benchmark suite |
|
||||
| [@Sameer6305](https://github.com/Sameer6305) | Apache AGE backend, PgVector store, Snowflake connector, Apache Arrow export |
|
||||
| [@tibisabau](https://github.com/tibisabau) | ArangoDB AQL export, Apache Parquet export |
|
||||
| [@d4ndr4d3](https://github.com/d4ndr4d3) | ResourceScheduler deadlock fix |
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 — Stable (2026-03-10)
|
||||
|
||||
### Context Graph Feature Completeness
|
||||
|
||||
**Temporal Validity Windows** (by @KaifAhmad1)
|
||||
|
||||
Nodes and edges now carry first-class `valid_from` / `valid_until` ISO datetime fields. These are stored directly on `ContextNode` and `ContextEdge` dataclasses — not in metadata — and survive full serialisation round-trips through `save_to_file()` / `load_from_file()` and `to_dict()` / `from_dict()`.
|
||||
|
||||
- `ContextNode.is_active(at_time=None)` and `ContextEdge.is_active(at_time=None)` — returns `True` if the node/edge is live at the given time (defaults to now). Handles both tz-aware and tz-naive datetime inputs correctly.
|
||||
- `ContextGraph.find_active_nodes(node_type=None, at_time=None)` — filters the entire graph and returns only nodes within their validity window.
|
||||
- `add_node(valid_from=..., valid_until=...)` and `add_edge(valid_from=..., valid_until=...)` — pass validity fields directly in the call signature.
|
||||
- Bug fix: `is_active()` previously crashed with `TypeError` when passed a tz-aware `datetime` (e.g. `datetime.now(timezone.utc)`). Fixed by normalising all inputs to tz-naive UTC via a new `_parse_iso_dt()` helper.
|
||||
- Bug fix: validity fields were silently lost in `add_nodes()`, `add_edges()`, `to_dict()`, and `from_dict()`. All four paths now correctly preserve and restore them.
|
||||
|
||||
**Weighted Multi-Hop BFS** (by @KaifAhmad1)
|
||||
|
||||
`ContextGraph.get_neighbors(node_id, hops=1, relationship_types=None, min_weight=0.0)` now accepts a `min_weight` threshold. Any edge with weight below the threshold is skipped during BFS traversal, allowing callers to confine multi-hop queries to high-confidence causal links. Default `0.0` is fully backward-compatible.
|
||||
|
||||
**Cross-Graph Navigation** (by @KaifAhmad1)
|
||||
|
||||
Separate `ContextGraph` instances can now be linked and navigated between — hierarchically, like separate knowledge domains that reference each other.
|
||||
|
||||
- `link_graph(other_graph, source_node_id, target_node_id, link_type="CROSS_GRAPH") -> str` — creates a navigable bridge and returns a `link_id`. Records a dedicated `"cross_graph_link"` typed marker node internally (not a phantom `"entity"`) and a marker edge.
|
||||
- `navigate_to(link_id) -> (other_graph, target_node_id)` — jumps to the target graph and entry node for a given link.
|
||||
- `graph_id` field — each `ContextGraph` now carries a stable UUID so instances can identify each other across save/load.
|
||||
- `save_to_file()` — now writes a `links` section alongside nodes and edges, containing `link_id`, `source_node_id`, `target_node_id`, and `other_graph_id` for every cross-graph link.
|
||||
- `load_from_file()` — restores `graph_id` and populates `_unresolved_links` from the `links` section.
|
||||
- `resolve_links(registry: Dict[str, ContextGraph]) -> int` — reconnects unresolved links post-load. Pass `{graph_id: graph_instance}` for each linked graph; returns the count of successfully resolved links. `navigate_to()` raises a clear `KeyError` with a `resolve_links()` hint if called before resolution.
|
||||
- Bug fix: the previous implementation auto-created the synthetic marker target as an `"entity"` node (phantom pollution). Fixed by explicitly pre-creating a `"cross_graph_link"` typed `ContextNode` before the marker edge.
|
||||
- 14 new tests in `tests/context/test_cross_graph_navigation.py` covering all scenarios including full save/load round-trips with partial registry resolution.
|
||||
|
||||
**Other Fixes** (by @KaifAhmad1)
|
||||
|
||||
- `PipelineBuilder.add_step()` return type annotation corrected from `"PipelineBuilder"` to `"PipelineStep"` — the implementation was already correct; only the annotation and docstring were stale.
|
||||
- `test_hybrid_search_performance` timing computation fixed — now accumulates a true `search_times` list instead of reusing the last loop iteration's `start_time`; threshold relaxed to `< 5.0s` for real `sentence-transformers` (384-dim) latency on development machines.
|
||||
|
||||
**Test Coverage Added**
|
||||
|
||||
- 14 cross-graph navigation tests (`tests/context/test_cross_graph_navigation.py`)
|
||||
- **Total: 335 context tests, 886+ tests across all modules — 0 failures**
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0-beta — Beta (2026-03-07)
|
||||
|
||||
### Semantic Extraction Fixes
|
||||
|
||||
**Multi-Founder LLM Extraction & Reasoner Inference Fix** (PR #354, by @KaifAhmad1)
|
||||
|
||||
- `_parse_relation_result` in `methods.py` — unmatched subjects/objects now produce a synthetic `UNKNOWN` entity instead of silently dropping the relation. All co-founders returned by the LLM are preserved in the output.
|
||||
- Duplicate relation fix — an orphaned legacy block that appended every relation twice has been removed.
|
||||
- `extraction_method` parameter added — typed extraction paths now correctly record `"llm_typed"` in relation metadata instead of `"llm"`.
|
||||
- `_match_pattern` in `reasoner.py` rewritten — splits patterns on `?var` placeholders first, then escapes only literal segments. Pre-bound variables resolve to exact literals, repeated variables use backreferences, non-greedy `.+?` prevents over-consumption of separators.
|
||||
- Added `tests/reasoning/test_reasoner.py` (4 tests) and `tests/semantic_extract/test_relation_extractor.py` (6 tests).
|
||||
|
||||
**TTL Export Alias Fix** (PR #355, by @KaifAhmad1)
|
||||
|
||||
- `RDFExporter` now accepts `"ttl"`, `"nt"`, `"xml"`, `"rdf"`, and `"json-ld"` as format aliases in `export_to_rdf()`. Aliases resolve before format validation — zero public API changes.
|
||||
- Added `tests/export/test_rdf_exporter.py` (8 tests).
|
||||
|
||||
### Incremental / Delta Processing
|
||||
|
||||
**Native Delta Computation** (PR #349, by @ZohaibHassan16, reviewed and fixed by @KaifAhmad1)
|
||||
|
||||
- Native SPARQL-based diff between graph snapshots — only changed triples flow through the pipeline.
|
||||
- `delta_mode` configuration in `PipelineBuilder` for near-real-time workloads.
|
||||
- Version snapshot management with graph URI tracking and metadata storage.
|
||||
- `prune_versions()` for automatic snapshot retention cleanup.
|
||||
- Bug fixes: corrected SPARQL variable order, fixed class references, resolved duplicate dictionary keys.
|
||||
|
||||
### Deduplication v2
|
||||
|
||||
**Candidate Generation v2** (PR #338, by @ZohaibHassan16)
|
||||
|
||||
- New opt-in strategies: `blocking_v2` and `hybrid_v2`, replacing O(N²) pair enumeration.
|
||||
- Multi-key blocking with normalised token prefixes, type-aware keys, and optional phonetic (Soundex) blocking.
|
||||
- Deterministic `max_candidates_per_entity` budgeting with stable sorting.
|
||||
- **63.6% faster** in worst-case scenarios (0.259s → 0.094s for 100 entities).
|
||||
|
||||
**Two-Stage Scoring Prefilter** (PR #339, by @ZohaibHassan16)
|
||||
|
||||
- Fast gates for type mismatch, name-length ratio, and token overlap eliminate expensive semantic scoring for obvious non-matches.
|
||||
- Configurable thresholds: `min_length_ratio`, `min_token_overlap_ratio`, `required_shared_token`.
|
||||
- **18–25% faster** batch processing with prefilter enabled (`prefilter_enabled=False` by default).
|
||||
|
||||
**Semantic Relationship Deduplication v2** (PR #340, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
||||
|
||||
- Canonicalisation engine with predicate synonym mapping (`works_for` → `employed_by`).
|
||||
- O(1) hash matching for exact canonical signatures.
|
||||
- Weighted scoring: 60% predicate + 40% object composition with explainable `semantic_match_score`.
|
||||
- **6.98x faster** than legacy mode (83ms vs 579ms).
|
||||
- `dedup_triplets()` infinite recursion bug fixed; function is now a first-class API in `methods.py`.
|
||||
|
||||
**Deduplication v2 Migration Guide** (PR #344, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
||||
|
||||
- Comprehensive `MIGRATION_V2.md` documenting all v2 strategies with code examples.
|
||||
- Full backward compatibility maintained — legacy mode remains the default.
|
||||
|
||||
### Export Formats
|
||||
|
||||
**ArangoDB AQL Export** (PR #342, by @tibisabau)
|
||||
|
||||
- Full AQL INSERT statement generation for vertices and edges.
|
||||
- Configurable collection names with validation and sanitisation; batch processing (default: 1000).
|
||||
- `export_arango()` convenience function; `.aql` auto-detection in the unified exporter.
|
||||
- 17 tests, 100% pass rate.
|
||||
|
||||
**Apache Parquet Export** (PR #343, by @tibisabau)
|
||||
|
||||
- Columnar storage format with configurable compression: snappy, gzip, brotli, zstd, lz4, none.
|
||||
- Explicit Apache Arrow schemas with type safety; field normalisation for varied naming conventions.
|
||||
- `export_parquet()` convenience function; `.parquet` auto-detection.
|
||||
- Analytics-ready for pandas, Spark, Snowflake, BigQuery, Databricks.
|
||||
- 25 tests, 100% pass rate.
|
||||
|
||||
### Bug Fixes & Test Suite Stabilisation
|
||||
|
||||
**Test Suite Fixes** (by @KaifAhmad1)
|
||||
|
||||
Context module:
|
||||
- `retrieve_decision_precedents` — gated entity extraction on `use_hybrid_search=True` correctly.
|
||||
- `_extract_entities_from_query` — now uses `word[0].isupper()` to capture camelCase identifiers like `CreditCard`.
|
||||
- Added missing `expand_context` (BFS traversal) and `_get_decision_query` methods.
|
||||
- Fixed `hybrid_retrieval`, `dynamic_context_traversal`, and `multi_hop_context_assembly` for correct single-pass BFS.
|
||||
- Fixed `_retrieve_from_vector` fallback to `result["metadata"]["content"]` to prevent empty content and negative re-ranking scores.
|
||||
|
||||
KG module:
|
||||
- `calculate_pagerank` — added `alpha`/`max_iter` aliases; return format changed to `{"centrality": scores, "rankings": sorted_list}`.
|
||||
- `community_detector._to_networkx` — now returns a NetworkX graph directly when one is passed (previously lost all edges).
|
||||
- Added 9 domain-specific tracking methods to `AlgorithmTrackerWithProvenance`.
|
||||
- Created `provenance_tracker.py` with `ProvenanceTracker` (`track_entity`, `get_all_sources`, `clear`).
|
||||
|
||||
Pipeline module:
|
||||
- Retry loop fixed — now correctly iterates to `max_retries`.
|
||||
- `RecoveryAction` dataclass and `handle_failure(error, policy, retry_count)` added with LINEAR, EXPONENTIAL, and FIXED strategies.
|
||||
- `add_step()` fixed to return the created `PipelineStep`.
|
||||
- `validate` added as alias for `validate_pipeline` in `PipelineValidator`.
|
||||
|
||||
Other:
|
||||
- Fixed `NameError` for missing `Type` import in `utils/helpers.py`.
|
||||
- Vector store performance threshold relaxed (100ms → 500ms per decision for development machines).
|
||||
- Windows cp1252 encoding fix in test files (emoji → ASCII).
|
||||
- `ProvenanceTracker` added to `semantica/kg/__init__.py` exports.
|
||||
|
||||
**Results: ~840 tests passing, 36 skipped (external services), 0 failed**
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0-alpha — Alpha (2026-02-19)
|
||||
|
||||
### Context & Decision Intelligence
|
||||
|
||||
**Context Engineering Enhancement** (PR #307, by @KaifAhmad1)
|
||||
|
||||
The foundational 0.3.0 feature — complete overhaul of the context module for production-grade decision intelligence:
|
||||
|
||||
- Full decision lifecycle: `record_decision()` → `add_decision()` → `add_causal_relationship()` → `trace_decision_chain()` → `analyze_decision_impact()` → `analyze_decision_influence()` → `find_similar_decisions()`
|
||||
- `AgentContext` unified wrapper with granular feature flags: `decision_tracking`, `kg_algorithms`, `graph_expansion`; methods: `store()`, `retrieve()`, `get_conversation_history()`, `get_statistics()`, `capture_cross_system_inputs()`
|
||||
- `AgentMemory` with working, conversation, and long-term memory tiers
|
||||
- `PolicyEngine` with versioned policy nodes, compliance checking (`check_decision_rules()`), and `PolicyException` model
|
||||
- Hybrid precedent search combining vector, structural, and category similarity with configurable weights
|
||||
- Decision influence analysis via centrality measures and causal chain tracking
|
||||
- GraphStore validation preventing runtime failures; secure logging
|
||||
- 9 critical bug fixes across logging, security, audit trails, API compatibility, Cypher queries, centrality access, validation
|
||||
|
||||
**Context Decision Tracking Fixes** (PR #315, by @KaifAhmad1)
|
||||
|
||||
- Fixed empty/None decision ID handling in `add_decision()`
|
||||
- Fixed None metadata handling preventing `TypeError`
|
||||
- Fixed causal chain depth logic and node exclusion
|
||||
- Fixed nonexistent node handling in `add_causal_relationship()`
|
||||
- Fixed precedent search direction in `find_precedents()`
|
||||
- Added missing `properties` field in `to_dict()`; added `from_dict()` method
|
||||
- Fixed UUID generation across all decision models
|
||||
- All 71 context tests passing
|
||||
|
||||
### Knowledge Graph Algorithms
|
||||
|
||||
**Improved Graph Algorithms** (PR #292, by @KaifAhmad1)
|
||||
|
||||
- 30+ graph algorithms across 7 categories
|
||||
- Node embeddings: Node2Vec, DeepWalk, Word2Vec via `NodeEmbedder`
|
||||
- Similarity: cosine, Euclidean, Manhattan, Correlation via `SimilarityCalculator`
|
||||
- Path finding: Dijkstra, A*, BFS, K-shortest paths via `PathFinder`
|
||||
- Link prediction: preferential attachment, Jaccard, Adamic-Adar via `LinkPredictor`
|
||||
- Centrality: degree, betweenness, closeness, PageRank via `CentralityAnalyzer`
|
||||
- Community detection: Louvain, Leiden, label propagation via `CommunityDetector`
|
||||
- Connectivity: components, bridges, density via `ConnectivityAnalyzer`
|
||||
- `GraphBuilderWithProvenance` and `AlgorithmTrackerWithProvenance` with full execution metadata
|
||||
|
||||
**Improved Vector Store for Decision Tracking** (PR #293, by @KaifAhmad1)
|
||||
|
||||
- `DecisionEmbeddingPipeline` with semantic and structural embeddings
|
||||
- `HybridSimilarityCalculator` with configurable weights (semantic: 0.7, structural: 0.3)
|
||||
- `ContextRetriever` with multi-hop reasoning
|
||||
- Convenience API: `quick_decision()`, `find_precedents()`, `explain()`, `similar_to()`, `batch_decisions()`, `filter_decisions()`
|
||||
- 34+ tests; performance: 0.028s per decision, 0.031s search, ~0.8KB memory per decision
|
||||
|
||||
### Graph Database Backends
|
||||
|
||||
**Apache AGE Backend Security Fixes** (PR #311, by @Sameer6305, fixes by @KaifAhmad1)
|
||||
|
||||
- `AgeStore` class with `GraphStore` API compatibility (openCypher via SQL on PostgreSQL)
|
||||
- SQL injection vulnerabilities fixed with input validation
|
||||
- psycopg2-binary dependency and migration guide added
|
||||
- Fixed parameter replacement and test mock leakage
|
||||
|
||||
**PgVector Store Support** (PR #303, by @Sameer6305, @KaifAhmad1)
|
||||
|
||||
- Native PostgreSQL vector storage using the pgvector extension
|
||||
- Multiple distance metrics: cosine, L2/Euclidean, inner product
|
||||
- HNSW and IVFFlat indexing for approximate nearest neighbour search
|
||||
- JSONB metadata storage with flexible filtering; batch operations
|
||||
- Connection pooling with psycopg3/psycopg2 fallback
|
||||
- SQL injection protection via `psycopg_sql.SQL()`; idempotent index and table management
|
||||
- 36+ tests with Docker integration
|
||||
|
||||
### Infrastructure
|
||||
|
||||
**ResourceScheduler Deadlock Fix** (PR #299, #301, by @d4ndr4d3, @KaifAhmad1)
|
||||
|
||||
- Replaced `threading.Lock()` with `threading.RLock()` to fix nested lock acquisition deadlock in `allocate_resources()`
|
||||
- Added `ValidationError` when no resources can be allocated
|
||||
- Progress tracking updates moved outside lock scope
|
||||
- 6 regression tests for deadlock prevention
|
||||
|
||||
**Security Configuration** (by @KaifAhmad1)
|
||||
|
||||
- Dependabot bi-weekly security updates with manual review
|
||||
- Automated security scans (Bandit, Safety, Semgrep) on schedule
|
||||
- Security-critical package grouping; zero auto-merge policy
|
||||
|
||||
---
|
||||
|
||||
## Summary by the Numbers
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total tests passing | **886+** |
|
||||
| Test failures | **0** |
|
||||
| Context tests | 335 |
|
||||
| KG tests | ~430 |
|
||||
| Semantic extraction tests | 70 (9 skipped — external LLM APIs) |
|
||||
| Reasoning tests | 19 |
|
||||
| Real-world scenario tests | 85 |
|
||||
| PyPI classifier | Production/Stable |
|
||||
| Python support | 3.8 – 3.12 |
|
||||
|
||||
---
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
pip install --upgrade semantica
|
||||
```
|
||||
|
||||
No breaking changes. All new parameters have safe defaults and all new methods are additive.
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the full line-by-line diff.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Deduplication & Conflict Resolution Strategies Summary
|
||||
|
||||
## Quick Reference by Use Case
|
||||
|
||||
| Use Case | Deduplication Method | Merge Strategy | Conflict Detection | Conflict Resolution |
|
||||
|----------|---------------------|----------------|-------------------|---------------------|
|
||||
| **Finance** |
|
||||
| `01_Financial_Data_Integration_MCP` | `DuplicateDetector` (incremental) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||
| `02_Fraud_Detection` | `ClusterBuilder` (graph_based) | `merge_all` | `logical` | `expert_review` |
|
||||
| **Biomedical** |
|
||||
| `01_Drug_Discovery_Pipeline` | `EntityResolver` (semantic) | - | `relationship` | `voting` |
|
||||
| `02_Genomic_Variant_Analysis` | `DuplicateDetector` (group) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||
| **Cybersecurity** |
|
||||
| `01_Real_Time_Anomaly_Detection` | `DuplicateDetector` (pairwise) | `keep_first` | `entity` | `first_seen` |
|
||||
| `02_Threat_Intelligence_Hybrid_RAG` | `EntityResolver` (exact) | - | `type` | `highest_confidence` |
|
||||
| **Blockchain** |
|
||||
| `01_DeFi_Protocol_Intelligence` | `DuplicateDetector` (group) | `keep_last` | `relationship` | `voting` |
|
||||
| `02_Transaction_Network_Analysis` | `ClusterBuilder` (hierarchical) | `keep_most_complete` | `temporal` | `most_recent` |
|
||||
| **Intelligence** |
|
||||
| `01_Criminal_Network_Analysis` | `EntityResolver` (fuzzy) | - | `value` | `credibility_weighted` |
|
||||
| `02_Intelligence_Analysis_Orchestrator_Worker` | `DuplicateDetector` (batch) | `merge_all` | `entity` | `voting` |
|
||||
| **Renewable Energy** |
|
||||
| `01_Energy_Market_Analysis` | `DuplicateDetector` (pairwise) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||
| **Supply Chain** |
|
||||
| `01_Supply_Chain_Data_Integration` | `DuplicateDetector` (incremental) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||
|
||||
---
|
||||
|
||||
## Strategy Rationale by Domain
|
||||
|
||||
### Finance
|
||||
- **Financial Data Integration**: Incremental for streaming data; most_recent for time-sensitive financial data
|
||||
- **Fraud Detection**: Graph-based clustering for fraud groups; expert_review for fraud assessment
|
||||
|
||||
### Biomedical
|
||||
- **Drug Discovery**: Semantic matching for drug compounds; voting for research source aggregation
|
||||
- **Genomic Variants**: Group method for related variants; credibility weighting for research sources
|
||||
|
||||
### Cybersecurity
|
||||
- **Real-Time Anomaly**: Pairwise for real-time streams; keep_first for first detection priority
|
||||
- **Threat Intelligence**: Exact matching for IOCs; highest_confidence for threat classification
|
||||
|
||||
### Blockchain
|
||||
- **DeFi Protocols**: Group method for related protocols; keep_last for latest protocol info
|
||||
- **Transaction Networks**: Hierarchical clustering for nested groups; temporal for time-sensitive data
|
||||
|
||||
### Intelligence
|
||||
- **Criminal Networks**: Fuzzy matching for intelligence data; credibility weighting for intelligence sources
|
||||
- **Intelligence Analysis**: Batch for multi-source integration; merge_all to combine all intelligence sources
|
||||
|
||||
### Renewable Energy
|
||||
- **Energy Markets**: Pairwise for real-time market data; most_recent for time-sensitive energy data
|
||||
|
||||
### Supply Chain
|
||||
- **Supply Chain Integration**: Incremental for continuous updates; credibility weighting for supply chain sources
|
||||
|
||||
---
|
||||
|
||||
## Method Distribution
|
||||
|
||||
### Deduplication Methods (9 total)
|
||||
- `pairwise`: 2 notebooks (real-time processing)
|
||||
- `batch`: 3 notebooks (large datasets)
|
||||
- `incremental`: 2 notebooks (streaming/continuous)
|
||||
- `group`: 2 notebooks (related entities)
|
||||
- `graph_based` (ClusterBuilder): 2 notebooks (interconnected entities)
|
||||
- `hierarchical` (ClusterBuilder): 1 notebook (nested groups)
|
||||
- `exact` (EntityResolver): 1 notebook (exact matching)
|
||||
- `semantic` (EntityResolver): 2 notebooks (semantic similarity)
|
||||
- `fuzzy` (EntityResolver): 1 notebook (fuzzy matching)
|
||||
|
||||
### Merge Strategies (5 total)
|
||||
- `keep_first`: 1 notebook (first detection priority)
|
||||
- `keep_last`: 1 notebook (latest information)
|
||||
- `keep_most_complete`: 5 notebooks (preserve all details)
|
||||
- `keep_highest_confidence`: 2 notebooks (most reliable data)
|
||||
- `merge_all`: 3 notebooks (combine all information)
|
||||
|
||||
### Conflict Detection Methods (6 total)
|
||||
- `value`: 4 notebooks (property value conflicts)
|
||||
- `type`: 2 notebooks (type/classification conflicts)
|
||||
- `entity`: 2 notebooks (entity-wide conflicts)
|
||||
- `relationship`: 3 notebooks (relationship conflicts)
|
||||
- `temporal`: 3 notebooks (time-sensitive conflicts)
|
||||
- `logical`: 2 notebooks (logical inconsistencies)
|
||||
|
||||
### Conflict Resolution Strategies (6 total)
|
||||
- `voting`: 5 notebooks (majority vote)
|
||||
- `credibility_weighted`: 4 notebooks (source credibility)
|
||||
- `most_recent`: 3 notebooks (latest data)
|
||||
- `first_seen`: 1 notebook (first detection)
|
||||
- `highest_confidence`: 2 notebooks (most confident)
|
||||
- `expert_review`: 1 notebook (manual review)
|
||||
|
||||
---
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Real-Time Systems**: Use `pairwise` + `keep_first` + `first_seen`
|
||||
2. **Time-Sensitive Data**: Use `temporal` + `most_recent`
|
||||
3. **Multi-Source Integration**: Use `batch` + `merge_all` + `voting`
|
||||
4. **Medical/Research**: Use `credibility_weighted` for authoritative sources
|
||||
5. **Fraud/Security**: Use `graph_based` + `logical` + `expert_review`
|
||||
6. **Exact Matching Required**: Use `exact` strategy (IOCs, identifiers)
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[](https://colab.research.google.com/github/Hawksight-AI/semantica/blob/main/cookbook/advanced/14_Datalog_Style_Reasoning.ipynb)\n",
|
||||
"\n",
|
||||
"# Datalog-Style Reasoning\n",
|
||||
"\n",
|
||||
"End-to-end guide to Semantica's **`DatalogReasoner`** — a native bottom-up semi-naive fixpoint engine — wired together with `GraphBuilder`, `ContextGraph`, `GraphAnalyzer`, `ExplanationGenerator`, and the supporting data-classes (`DatalogFact`, `DatalogRule`, `InferenceResult`, `Rule`).\n",
|
||||
"\n",
|
||||
"## What you will build\n",
|
||||
"\n",
|
||||
"| Part | Topic | Key classes |\n",
|
||||
"|------|-------|-------------|\n",
|
||||
"| 1 | Core API & EDB/IDB concepts | `DatalogReasoner`, `DatalogFact`, `DatalogRule` |\n",
|
||||
"| 2 | KG → Datalog pipeline | `GraphBuilder`, `GraphAnalyzer`, `DatalogReasoner` |\n",
|
||||
"| 3 | ContextGraph integration | `ContextGraph`, `DatalogReasoner.load_from_graph()` |\n",
|
||||
"| 4 | RBAC access-control policy | `GraphBuilder`, `DatalogReasoner`, `ExplanationGenerator` |\n",
|
||||
"| 5 | Org hierarchy | `ContextGraph`, `DatalogReasoner`, `InferenceResult` |\n",
|
||||
"| 6 | Engine introspection | `DatalogFact`, `DatalogRule` internal state |\n",
|
||||
"\n",
|
||||
"**Related notebooks**\n",
|
||||
"- [08_Reasoning_and_Inference.ipynb](08_Reasoning_and_Inference.ipynb) — high-level `Reasoner` with IF/THEN syntax\n",
|
||||
"- [10_Temporal_Knowledge_Graphs.ipynb](10_Temporal_Knowledge_Graphs.ipynb) — temporal reasoning\n",
|
||||
"\n",
|
||||
"**Documentation**: [Reasoning API](https://semantica.readthedocs.io/reference/reasoning/) | [KG API](https://semantica.readthedocs.io/reference/kg/) | [Context API](https://semantica.readthedocs.io/reference/context/)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -qU semantica"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Reasoning ──────────────────────────────────────────────────────────────\n",
|
||||
"from semantica.reasoning import (\n",
|
||||
" DatalogReasoner, # native Datalog fixpoint engine\n",
|
||||
" DatalogFact, # frozen dataclass: predicate + args tuple\n",
|
||||
" DatalogRule, # dataclass: head + body (list[BodyAtom])\n",
|
||||
" ExplanationGenerator, # generates NL justifications\n",
|
||||
" InferenceResult, # result dataclass consumed by ExplanationGenerator\n",
|
||||
" Rule, # rule dataclass used by ExplanationGenerator\n",
|
||||
" RuleType, # enum: IMPLICATION | EQUIVALENCE | CONSTRAINT | TRANSFORMATION\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# ── Knowledge Graph ────────────────────────────────────────────────────────\n",
|
||||
"from semantica.kg import (\n",
|
||||
" GraphBuilder, # constructs KG dicts from entity+relationship sources\n",
|
||||
" GraphAnalyzer, # centrality, communities, connectivity, metrics\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# ── Context ────────────────────────────────────────────────────────────────\n",
|
||||
"from semantica.context import ContextGraph # in-memory graph: add_node/add_edge/find_*\n",
|
||||
"\n",
|
||||
"print(\"All Semantica classes imported successfully.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 1 — Core API: EDB Facts, IDB Rules, Fixpoint\n",
|
||||
"\n",
|
||||
"### Datalog in 30 seconds\n",
|
||||
"\n",
|
||||
"| Term | Meaning | Example |\n",
|
||||
"|------|---------|--------|\n",
|
||||
"| EDB (Extensional DB) | Ground facts you assert | `parent(tom, bob)` |\n",
|
||||
"| IDB (Intensional DB) | Facts derived by rules | `ancestor(tom, ann)` |\n",
|
||||
"| Rule (Horn clause) | If body → derive head | `ancestor(X,Y) :- parent(X,Y).` |\n",
|
||||
"| Variable | Uppercase, unified during eval | `X`, `Y`, `Role` |\n",
|
||||
"| Constant | Lowercase, matches literally | `tom`, `admin` |\n",
|
||||
"| Fixpoint | Iterate until no new facts appear | `DatalogReasoner.derive_all()` |\n",
|
||||
"\n",
|
||||
"### The canonical example — transitive ancestry"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Step 1: create engine ──────────────────────────────────────────────────\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"\n",
|
||||
"# ── Step 2: load EDB (ground facts) ───────────────────────────────────────\n",
|
||||
"# Syntax: predicate(constant1, constant2) — constants must be lowercase\n",
|
||||
"edb_facts = [\n",
|
||||
" \"parent(tom, bob)\",\n",
|
||||
" \"parent(bob, ann)\",\n",
|
||||
" \"parent(ann, pat)\",\n",
|
||||
"]\n",
|
||||
"for f in edb_facts:\n",
|
||||
" dr.add_fact(f)\n",
|
||||
"\n",
|
||||
"print(f\"EDB loaded: {len(dr._all_facts)} ground facts\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Step 3: add IDB rules (Horn clauses) ──────────────────────────────────\n",
|
||||
"# Syntax: head(Vars) :- body_atom1(Vars), body_atom2(Vars).\n",
|
||||
"# Variables start with uppercase; trailing '.' is optional\n",
|
||||
"dr.add_rule(\"ancestor(X, Y) :- parent(X, Y).\")\n",
|
||||
"dr.add_rule(\"ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).\") # recursive\n",
|
||||
"\n",
|
||||
"print(f\"Rules loaded: {len(dr._rules)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Step 4: fixpoint evaluation ────────────────────────────────────────────\n",
|
||||
"# derive_all() runs semi-naive bottom-up evaluation until no new facts appear\n",
|
||||
"all_facts: list[str] = dr.derive_all()\n",
|
||||
"\n",
|
||||
"ancestor_strs = sorted(f for f in all_facts if f.startswith(\"ancestor\"))\n",
|
||||
"print(f\"Derived {len(ancestor_strs)} ancestor facts:\")\n",
|
||||
"for f in ancestor_strs:\n",
|
||||
" print(\" \", f)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Step 5: query ──────────────────────────────────────────────────────────\n",
|
||||
"# Use '?varname' placeholders — query() auto-calls derive_all() if needed\n",
|
||||
"# Returns: list[dict] e.g. [{\"Y\": \"bob\"}, {\"Y\": \"ann\"}, {\"Y\": \"pat\"}]\n",
|
||||
"\n",
|
||||
"descendants = dr.query(\"ancestor(tom, ?Y)\")\n",
|
||||
"print(\"All descendants of tom:\", sorted(r[\"Y\"] for r in descendants))\n",
|
||||
"\n",
|
||||
"ancestors_of_pat = dr.query(\"ancestor(?X, pat)\")\n",
|
||||
"print(\"All ancestors of pat: \", sorted(r[\"X\"] for r in ancestors_of_pat))\n",
|
||||
"\n",
|
||||
"all_pairs = dr.query(\"ancestor(?X, ?Y)\")\n",
|
||||
"print(f\"\\nAll ancestor pairs ({len(all_pairs)}):\")\n",
|
||||
"for row in sorted(all_pairs, key=lambda r: (r[\"X\"], r[\"Y\"])):\n",
|
||||
" print(f\" {row['X']:6s} → {row['Y']}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 2 — GraphBuilder → DatalogReasoner Pipeline\n",
|
||||
"\n",
|
||||
"`GraphBuilder` constructs a structured `{\"entities\": [...], \"relationships\": [...]}` dict from your data. We then:\n",
|
||||
"\n",
|
||||
"1. Analyse the graph with `GraphAnalyzer` to understand structure.\n",
|
||||
"2. Feed `kg[\"relationships\"]` into `DatalogReasoner` as EDB facts.\n",
|
||||
"3. Apply recursive Datalog rules over the KG."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Build a software-dependency KG ────────────────────────────────────────\n",
|
||||
"entities = [\n",
|
||||
" {\"id\": \"pythonsdk\", \"name\": \"Python SDK\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"restapi\", \"name\": \"REST API\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"authservice\", \"name\": \"Auth Service\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"database\", \"name\": \"Database\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"dashboard\", \"name\": \"Dashboard\", \"type\": \"Component\"},\n",
|
||||
" {\"id\": \"analytics\", \"name\": \"Analytics\", \"type\": \"Component\"},\n",
|
||||
"]\n",
|
||||
"relationships = [\n",
|
||||
" {\"source\": \"pythonsdk\", \"target\": \"restapi\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"restapi\", \"target\": \"authservice\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"authservice\", \"target\": \"database\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"dashboard\", \"target\": \"restapi\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"dashboard\", \"target\": \"analytics\", \"type\": \"depends_on\"},\n",
|
||||
" {\"source\": \"analytics\", \"target\": \"database\", \"type\": \"depends_on\"},\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"# GraphBuilder validates, deduplicates, and packages the data\n",
|
||||
"builder = GraphBuilder(merge_entities=True, resolve_conflicts=False)\n",
|
||||
"kg = builder.build([{\"entities\": entities, \"relationships\": relationships}])\n",
|
||||
"\n",
|
||||
"print(f\"KG built — entities: {len(kg['entities'])}, relationships: {len(kg['relationships'])}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Analyse the graph structure before reasoning ───────────────────────────\n",
|
||||
"# GraphAnalyzer provides centrality, communities, connectivity, and metrics\n",
|
||||
"analyzer = GraphAnalyzer()\n",
|
||||
"metrics = analyzer.compute_metrics(graph=kg)\n",
|
||||
"\n",
|
||||
"print(\"Graph structure:\")\n",
|
||||
"print(f\" Nodes : {metrics['num_nodes']}\")\n",
|
||||
"print(f\" Edges : {metrics['num_edges']}\")\n",
|
||||
"if \"density\" in metrics:\n",
|
||||
" print(f\" Density : {metrics['density']:.3f}\")\n",
|
||||
"if \"is_connected\" in metrics:\n",
|
||||
" print(f\" Connected : {metrics['is_connected']}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Load KG relationships as EDB facts ────────────────────────────────────\n",
|
||||
"# GraphBuilder output dicts use the same source/target/type shape that\n",
|
||||
"# DatalogReasoner.add_fact() natively understands\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"\n",
|
||||
"for rel in kg[\"relationships\"]:\n",
|
||||
" dr.add_fact(rel) # dict path: {\"source\": ..., \"target\": ..., \"type\": ...}\n",
|
||||
"\n",
|
||||
"print(f\"EDB loaded: {len(dr._all_facts)} dependency facts\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Transitive dependency closure ─────────────────────────────────────────\n",
|
||||
"# 'depends_on' is the predicate name that add_fact inferred from 'type'\n",
|
||||
"dr.add_rule(\"transitive_dep(X, Y) :- depends_on(X, Y).\")\n",
|
||||
"dr.add_rule(\"transitive_dep(X, Y) :- depends_on(X, Z), transitive_dep(Z, Y).\")\n",
|
||||
"\n",
|
||||
"dr.derive_all()\n",
|
||||
"\n",
|
||||
"# Everything that transitively depends on the database\n",
|
||||
"db_deps = sorted(r[\"X\"] for r in dr.query(\"transitive_dep(?X, database)\"))\n",
|
||||
"print(\"Components that transitively depend on Database:\")\n",
|
||||
"for c in db_deps:\n",
|
||||
" print(\" \", c)\n",
|
||||
"\n",
|
||||
"# What does pythonsdk transitively depend on?\n",
|
||||
"sdk_chain = sorted(r[\"Y\"] for r in dr.query(\"transitive_dep(pythonsdk, ?Y)\"))\n",
|
||||
"print(f\"\\nPython SDK full dependency chain: {sdk_chain}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 3 — ContextGraph + `load_from_graph()`\n",
|
||||
"\n",
|
||||
"`DatalogReasoner.load_from_graph(graph)` accepts any `ContextGraph` directly: it calls `graph.find_edges()` and `graph.find_nodes()` and converts each result into EDB facts automatically."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Build an in-memory ContextGraph ───────────────────────────────────────\n",
|
||||
"# ContextGraph.add_node / add_edge are the canonical way to build in-memory KGs\n",
|
||||
"cg = ContextGraph()\n",
|
||||
"\n",
|
||||
"# Nodes\n",
|
||||
"for person in [\"alice\", \"bob\", \"carol\", \"dave\", \"eve\"]:\n",
|
||||
" cg.add_node(person, node_type=\"person\", name=person.capitalize())\n",
|
||||
"\n",
|
||||
"# Directed \"follows\" edges\n",
|
||||
"for src, dst in [(\"alice\", \"bob\"), (\"bob\", \"carol\"), (\"carol\", \"dave\"), (\"alice\", \"eve\"), (\"eve\", \"carol\")]:\n",
|
||||
" cg.add_edge(src, dst, edge_type=\"follows\")\n",
|
||||
"\n",
|
||||
"# Verify the graph built correctly\n",
|
||||
"nodes = cg.find_nodes(node_type=\"person\")\n",
|
||||
"edges = cg.find_edges(edge_type=\"follows\")\n",
|
||||
"print(f\"ContextGraph — nodes: {len(nodes)}, edges: {len(edges)}\")\n",
|
||||
"print(\"Edges:\", [(e.get(\"source\", e.get(\"source_id\")), e.get(\"target\", e.get(\"target_id\"))) for e in edges])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── load_from_graph() ingests the ContextGraph directly ───────────────────\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"n_loaded = dr.load_from_graph(cg) # calls cg.find_edges() + cg.find_nodes() internally\n",
|
||||
"print(f\"Facts loaded from ContextGraph: {n_loaded}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Influence reach via transitive 'follows' ──────────────────────────────\n",
|
||||
"dr.add_rule(\"influence(X, Y) :- follows(X, Y).\")\n",
|
||||
"dr.add_rule(\"influence(X, Y) :- follows(X, Z), influence(Z, Y).\")\n",
|
||||
"\n",
|
||||
"dr.derive_all()\n",
|
||||
"\n",
|
||||
"# Who can alice reach?\n",
|
||||
"alice_reach = sorted(r[\"Y\"] for r in dr.query(\"influence(alice, ?Y)\"))\n",
|
||||
"print(f\"Alice's influence reach : {alice_reach}\")\n",
|
||||
"\n",
|
||||
"# Who can reach dave?\n",
|
||||
"reach_dave = sorted(r[\"X\"] for r in dr.query(\"influence(?X, dave)\"))\n",
|
||||
"print(f\"Who can influence dave : {reach_dave}\")\n",
|
||||
"\n",
|
||||
"# Full influence matrix\n",
|
||||
"all_influence = dr.query(\"influence(?X, ?Y)\")\n",
|
||||
"print(f\"\\nTotal influence pairs: {len(all_influence)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 4 — RBAC Access-Control Policy\n",
|
||||
"\n",
|
||||
"We model a role-based access-control (RBAC) system:\n",
|
||||
"\n",
|
||||
"1. Use `GraphBuilder` to build a structured KG of users, roles, and permissions.\n",
|
||||
"2. Load it into `DatalogReasoner` for policy inference.\n",
|
||||
"3. Use `ExplanationGenerator` to produce audit-ready NL justifications."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Build RBAC graph with GraphBuilder ────────────────────────────────────\n",
|
||||
"rbac_entities = [\n",
|
||||
" # Users\n",
|
||||
" {\"id\": \"alice\", \"type\": \"User\", \"name\": \"Alice\"},\n",
|
||||
" {\"id\": \"bob\", \"type\": \"User\", \"name\": \"Bob\"},\n",
|
||||
" {\"id\": \"carol\", \"type\": \"User\", \"name\": \"Carol\"},\n",
|
||||
" {\"id\": \"dave\", \"type\": \"User\", \"name\": \"Dave\"},\n",
|
||||
" # Roles\n",
|
||||
" {\"id\": \"admin\", \"type\": \"Role\", \"name\": \"Administrator\"},\n",
|
||||
" {\"id\": \"editor\", \"type\": \"Role\", \"name\": \"Editor\"},\n",
|
||||
" {\"id\": \"viewer\", \"type\": \"Role\", \"name\": \"Viewer\"},\n",
|
||||
" # Permissions\n",
|
||||
" {\"id\": \"read\", \"type\": \"Permission\"},\n",
|
||||
" {\"id\": \"write\", \"type\": \"Permission\"},\n",
|
||||
" {\"id\": \"delete\", \"type\": \"Permission\"},\n",
|
||||
" {\"id\": \"manage_users\", \"type\": \"Permission\"},\n",
|
||||
"]\n",
|
||||
"rbac_relationships = [\n",
|
||||
" # User → Role assignments\n",
|
||||
" {\"source\": \"alice\", \"target\": \"admin\", \"type\": \"has_role\"},\n",
|
||||
" {\"source\": \"bob\", \"target\": \"editor\", \"type\": \"has_role\"},\n",
|
||||
" {\"source\": \"carol\", \"target\": \"viewer\", \"type\": \"has_role\"},\n",
|
||||
" {\"source\": \"dave\", \"target\": \"editor\", \"type\": \"has_role\"},\n",
|
||||
" # Role hierarchy (admin inherits from editor, editor from viewer)\n",
|
||||
" {\"source\": \"admin\", \"target\": \"editor\", \"type\": \"role_inherits\"},\n",
|
||||
" {\"source\": \"editor\", \"target\": \"viewer\", \"type\": \"role_inherits\"},\n",
|
||||
" # Role → Permission grants\n",
|
||||
" {\"source\": \"viewer\", \"target\": \"read\", \"type\": \"role_has_perm\"},\n",
|
||||
" {\"source\": \"editor\", \"target\": \"write\", \"type\": \"role_has_perm\"},\n",
|
||||
" {\"source\": \"admin\", \"target\": \"delete\", \"type\": \"role_has_perm\"},\n",
|
||||
" {\"source\": \"admin\", \"target\": \"manage_users\", \"type\": \"role_has_perm\"},\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"builder = GraphBuilder(merge_entities=True, resolve_conflicts=False)\n",
|
||||
"rbac_kg = builder.build([{\"entities\": rbac_entities, \"relationships\": rbac_relationships}])\n",
|
||||
"\n",
|
||||
"print(f\"RBAC KG — entities: {len(rbac_kg['entities'])}, relationships: {len(rbac_kg['relationships'])}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Analyse RBAC graph structure ──────────────────────────────────────────\n",
|
||||
"analyzer = GraphAnalyzer()\n",
|
||||
"metrics = analyzer.compute_metrics(graph=rbac_kg)\n",
|
||||
"centrality = analyzer.calculate_centrality(rbac_kg, centrality_type=\"degree\")\n",
|
||||
"\n",
|
||||
"print(f\"RBAC graph — {metrics['num_nodes']} nodes, {metrics['num_edges']} edges\")\n",
|
||||
"if isinstance(centrality, dict) and \"degree\" in centrality:\n",
|
||||
" top = sorted(centrality[\"degree\"].items(), key=lambda x: x[1], reverse=True)[:3]\n",
|
||||
" print(\"Top-3 nodes by degree centrality:\", top)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Load RBAC KG into DatalogReasoner ────────────────────────────────────\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"\n",
|
||||
"for rel in rbac_kg[\"relationships\"]:\n",
|
||||
" dr.add_fact(rel) # {source, target, type} → predicate(source, target)\n",
|
||||
"\n",
|
||||
"# ── IDB rules: transitive role hierarchy ─────────────────────────────────\n",
|
||||
"dr.add_rule(\"effective_role(R, R2) :- role_inherits(R, R2).\")\n",
|
||||
"dr.add_rule(\"effective_role(R, R2) :- role_inherits(R, Z), effective_role(Z, R2).\")\n",
|
||||
"\n",
|
||||
"# ── IDB rules: inherited permissions ─────────────────────────────────────\n",
|
||||
"dr.add_rule(\"role_can(R, P) :- role_has_perm(R, P).\")\n",
|
||||
"dr.add_rule(\"role_can(R, P) :- effective_role(R, R2), role_has_perm(R2, P).\")\n",
|
||||
"\n",
|
||||
"# ── IDB rules: user effective permissions ────────────────────────────────\n",
|
||||
"dr.add_rule(\"can(U, P) :- has_role(U, R), role_can(R, P).\")\n",
|
||||
"\n",
|
||||
"dr.derive_all()\n",
|
||||
"\n",
|
||||
"print(\"User permissions derived via role-hierarchy inference:\")\n",
|
||||
"for user in [\"alice\", \"bob\", \"carol\", \"dave\"]:\n",
|
||||
" perms = sorted(r[\"P\"] for r in dr.query(f\"can({user}, ?P)\"))\n",
|
||||
" print(f\" {user:6s}: {perms}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── ExplanationGenerator — audit-ready NL justification ──────────────────\n",
|
||||
"# ExplanationGenerator works with InferenceResult objects.\n",
|
||||
"# We construct one manually to represent a derived Datalog conclusion.\n",
|
||||
"\n",
|
||||
"explainer = ExplanationGenerator(detail_level=\"detailed\")\n",
|
||||
"\n",
|
||||
"# Build the Rule object that represents the permission derivation chain\n",
|
||||
"perm_rule = Rule(\n",
|
||||
" rule_id=\"rbac_perm_chain\",\n",
|
||||
" name=\"RBAC permission via role hierarchy\",\n",
|
||||
" conditions=[\"has_role(alice, admin)\", \"effective_role(admin, viewer)\", \"role_has_perm(viewer, read)\"],\n",
|
||||
" conclusion=\"can(alice, read)\",\n",
|
||||
" rule_type=RuleType.IMPLICATION,\n",
|
||||
" confidence=1.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Build InferenceResult representing the Datalog conclusion\n",
|
||||
"result = InferenceResult(\n",
|
||||
" conclusion=\"can(alice, read)\",\n",
|
||||
" rule_used=perm_rule,\n",
|
||||
" premises=[\n",
|
||||
" \"has_role(alice, admin)\",\n",
|
||||
" \"role_inherits(admin, editor)\",\n",
|
||||
" \"role_inherits(editor, viewer)\",\n",
|
||||
" \"role_has_perm(viewer, read)\",\n",
|
||||
" ],\n",
|
||||
" confidence=1.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Generate NL explanation\n",
|
||||
"explanation = explainer.generate_explanation(result)\n",
|
||||
"print(\"Explanation type :\", explanation.explanation_type)\n",
|
||||
"print(\"Conclusion :\", explanation.conclusion)\n",
|
||||
"print(\"Natural language :\", explanation.natural_language)\n",
|
||||
"print(\"Reasoning steps :\", len(explanation.reasoning_path.steps))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Inverse queries ───────────────────────────────────────────────────────\n",
|
||||
"deleters = sorted(r[\"U\"] for r in dr.query(\"can(?U, delete)\"))\n",
|
||||
"print(\"Who can delete:\", deleters)\n",
|
||||
"\n",
|
||||
"writers = sorted(r[\"U\"] for r in dr.query(\"can(?U, write)\"))\n",
|
||||
"print(\"Who can write: \", writers)\n",
|
||||
"\n",
|
||||
"# All (user, permission) pairs — full policy matrix\n",
|
||||
"all_caps = dr.query(\"can(?U, ?P)\")\n",
|
||||
"print(f\"\\nTotal (user, permission) pairs: {len(all_caps)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 5 — Organisation Hierarchy with ContextGraph\n",
|
||||
"\n",
|
||||
"We model a company org-chart using `ContextGraph` and derive:\n",
|
||||
"- `manages(M, E)` — direct and transitive management\n",
|
||||
"- `skip_level(M, E)` — two hops up the chain\n",
|
||||
"- `same_team(X, Y)` — shared team membership"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── ContextGraph: org chart ────────────────────────────────────────────────\n",
|
||||
"org = ContextGraph()\n",
|
||||
"\n",
|
||||
"# Add employees as nodes with metadata\n",
|
||||
"staff = [\n",
|
||||
" (\"eng1\", \"engineer\", \"backend\"),\n",
|
||||
" (\"eng2\", \"engineer\", \"backend\"),\n",
|
||||
" (\"eng3\", \"engineer\", \"frontend\"),\n",
|
||||
" (\"techlead\", \"lead\", \"engineering\"),\n",
|
||||
" (\"design1\", \"designer\", \"ux\"),\n",
|
||||
" (\"design2\", \"designer\", \"ux\"),\n",
|
||||
" (\"designlead\",\"lead\", \"design\"),\n",
|
||||
" (\"vpeng\", \"vp\", \"engineering\"),\n",
|
||||
" (\"cto\", \"executive\", \"leadership\"),\n",
|
||||
"]\n",
|
||||
"for emp_id, role, team in staff:\n",
|
||||
" org.add_node(emp_id, node_type=\"employee\", role=role, team=team)\n",
|
||||
"\n",
|
||||
"# Reporting lines\n",
|
||||
"reports_to = [\n",
|
||||
" (\"eng1\", \"techlead\"), (\"eng2\", \"techlead\"), (\"eng3\", \"techlead\"),\n",
|
||||
" (\"techlead\", \"vpeng\"),\n",
|
||||
" (\"design1\", \"designlead\"), (\"design2\", \"designlead\"),\n",
|
||||
" (\"designlead\", \"vpeng\"),\n",
|
||||
" (\"vpeng\", \"cto\"),\n",
|
||||
"]\n",
|
||||
"for employee, manager in reports_to:\n",
|
||||
" org.add_edge(employee, manager, edge_type=\"reports_to\")\n",
|
||||
"\n",
|
||||
"# Team membership edges\n",
|
||||
"teams = [\n",
|
||||
" (\"eng1\", \"backend\"), (\"eng2\", \"backend\"), (\"eng3\", \"frontend\"),\n",
|
||||
" (\"design1\", \"ux\"), (\"design2\", \"ux\"),\n",
|
||||
"]\n",
|
||||
"for emp, team in teams:\n",
|
||||
" org.add_edge(emp, team, edge_type=\"in_team\")\n",
|
||||
" if not org.find_nodes(node_type=\"team\"):\n",
|
||||
" org.add_node(team, node_type=\"team\")\n",
|
||||
"\n",
|
||||
"print(f\"ContextGraph — nodes: {len(org.find_nodes())}, edges: {len(org.find_edges())}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Load org chart into DatalogReasoner ───────────────────────────────────\n",
|
||||
"dr = DatalogReasoner()\n",
|
||||
"n = dr.load_from_graph(org) # uses org.find_edges() + org.find_nodes()\n",
|
||||
"print(f\"Facts loaded via load_from_graph(): {n}\")\n",
|
||||
"\n",
|
||||
"# ── IDB rules ─────────────────────────────────────────────────────────────\n",
|
||||
"# Transitive management chain\n",
|
||||
"dr.add_rule(\"manages(M, E) :- reports_to(E, M).\")\n",
|
||||
"dr.add_rule(\"manages(M, E) :- reports_to(E, Z), manages(M, Z).\")\n",
|
||||
"\n",
|
||||
"# Skip-level: exactly two reporting hops\n",
|
||||
"dr.add_rule(\"skip_level(M, E) :- reports_to(E, Z), reports_to(Z, M).\")\n",
|
||||
"\n",
|
||||
"# Same team\n",
|
||||
"dr.add_rule(\"same_team(X, Y) :- in_team(X, T), in_team(Y, T).\")\n",
|
||||
"\n",
|
||||
"dr.derive_all()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Query org hierarchy ────────────────────────────────────────────────────\n",
|
||||
"# Everyone under CTO\n",
|
||||
"under_cto = sorted(r[\"E\"] for r in dr.query(\"manages(cto, ?E)\"))\n",
|
||||
"print(f\"CTO manages ({len(under_cto)} people): {under_cto}\")\n",
|
||||
"\n",
|
||||
"# VP Eng's direct + indirect reports\n",
|
||||
"under_vp = sorted(r[\"E\"] for r in dr.query(\"manages(vpeng, ?E)\"))\n",
|
||||
"print(f\"VP Eng manages : {under_vp}\")\n",
|
||||
"\n",
|
||||
"# Skip-level reports to CTO (people two hops below CTO)\n",
|
||||
"skip = sorted(r[\"E\"] for r in dr.query(\"skip_level(cto, ?E)\"))\n",
|
||||
"print(f\"CTO skip-level reports : {skip}\")\n",
|
||||
"\n",
|
||||
"# eng1's teammates\n",
|
||||
"mates = [r[\"Y\"] for r in dr.query(\"same_team(eng1, ?Y)\") if r[\"Y\"] != \"eng1\"]\n",
|
||||
"print(f\"eng1's teammates : {sorted(mates)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Build an InferenceResult and explain an org query ────────────────────\n",
|
||||
"explainer = ExplanationGenerator(detail_level=\"verbose\")\n",
|
||||
"\n",
|
||||
"mgmt_rule = Rule(\n",
|
||||
" rule_id=\"transitive_manages\",\n",
|
||||
" name=\"Transitive management chain\",\n",
|
||||
" conditions=[\"reports_to(eng1, techlead)\", \"manages(vpeng, techlead)\"],\n",
|
||||
" conclusion=\"manages(vpeng, eng1)\",\n",
|
||||
" rule_type=RuleType.IMPLICATION,\n",
|
||||
" confidence=1.0,\n",
|
||||
")\n",
|
||||
"result = InferenceResult(\n",
|
||||
" conclusion=\"manages(vpeng, eng1)\",\n",
|
||||
" rule_used=mgmt_rule,\n",
|
||||
" premises=[\"reports_to(eng1, techlead)\", \"reports_to(techlead, vpeng)\"],\n",
|
||||
" confidence=1.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"exp = explainer.generate_explanation(result)\n",
|
||||
"print(exp.natural_language)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Part 6 — Engine Introspection: DatalogFact & DatalogRule\n",
|
||||
"\n",
|
||||
"After reasoning, the engine's internal state is fully accessible via `DatalogFact` and `DatalogRule` data-classes. Use this for auditing, debugging, or downstream export."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Inspect DatalogRule objects ────────────────────────────────────────────\n",
|
||||
"# dr._rules → List[DatalogRule]\n",
|
||||
"# DatalogRule.head_predicate, .head_args, .body (body = List[BodyAtom])\n",
|
||||
"print(\"Rules in engine:\")\n",
|
||||
"for rule in dr._rules:\n",
|
||||
" body_str = \", \".join(\n",
|
||||
" f\"{atom.predicate}({', '.join(atom.args)})\"\n",
|
||||
" for atom in rule.body\n",
|
||||
" )\n",
|
||||
" head_str = f\"{rule.head_predicate}({', '.join(rule.head_args)})\"\n",
|
||||
" print(f\" {head_str} :- {body_str}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Inspect DatalogFact objects ────────────────────────────────────────────\n",
|
||||
"# dr._all_facts → Set[DatalogFact] (EDB + IDB combined after derive_all)\n",
|
||||
"# dr._fact_index → Dict[predicate, Set[DatalogFact]]\n",
|
||||
"\n",
|
||||
"from collections import Counter\n",
|
||||
"\n",
|
||||
"# Count facts per predicate\n",
|
||||
"predicate_counts = Counter(f.predicate for f in dr._all_facts)\n",
|
||||
"print(\"Facts per predicate (EDB + derived IDB):\")\n",
|
||||
"for pred, count in sorted(predicate_counts.items()):\n",
|
||||
" print(f\" {pred:20s}: {count}\")\n",
|
||||
"print(f\"\\n TOTAL: {len(dr._all_facts)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Separate EDB from IDB ─────────────────────────────────────────────────\n",
|
||||
"# EDB predicates are the ones we added via add_fact (not derived by rules)\n",
|
||||
"idb_predicates = {rule.head_predicate for rule in dr._rules}\n",
|
||||
"edb_predicates = {f.predicate for f in dr._all_facts} - idb_predicates\n",
|
||||
"\n",
|
||||
"print(f\"EDB predicates (base facts) : {sorted(edb_predicates)}\")\n",
|
||||
"print(f\"IDB predicates (derived) : {sorted(idb_predicates)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Sample DatalogFact structure ──────────────────────────────────────────\n",
|
||||
"# DatalogFact is a frozen dataclass: predicate: str, args: Tuple[str, ...]\n",
|
||||
"manages_facts = sorted(dr._fact_index.get(\"manages\", []), key=lambda f: f.args)\n",
|
||||
"print(f\"First 5 'manages' DatalogFact objects ({len(manages_facts)} total):\")\n",
|
||||
"for fact in manages_facts[:5]:\n",
|
||||
" # Access predicate and args directly from the dataclass\n",
|
||||
" print(f\" DatalogFact(predicate={fact.predicate!r}, args={fact.args})\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── clear() resets the engine completely ─────────────────────────────────\n",
|
||||
"print(f\"Facts before clear(): {len(dr._all_facts)}\")\n",
|
||||
"dr.clear()\n",
|
||||
"print(f\"Facts after clear(): {len(dr._all_facts)}\")\n",
|
||||
"print(f\"Rules after clear(): {len(dr._rules)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## API Summary\n",
|
||||
"\n",
|
||||
"### DatalogReasoner\n",
|
||||
"\n",
|
||||
"| Method | Input | Output | Notes |\n",
|
||||
"|--------|-------|--------|-------|\n",
|
||||
"| `add_fact(f)` | `str` or `dict` | `None` | string: `\"pred(a, b)\"` · dict: `{source, target, type}` |\n",
|
||||
"| `add_rule(s)` | `str` | `None` | Horn clause: `\"head(X) :- body(X, Y).\"` |\n",
|
||||
"| `derive_all()` | — | `list[str]` | semi-naive fixpoint; idempotent |\n",
|
||||
"| `query(pat)` | `str` | `list[dict]` | `\"pred(a, ?Y)\"` → `[{\"Y\": ...}]` |\n",
|
||||
"| `load_from_graph(g)` | `ContextGraph` | `int` | facts loaded count |\n",
|
||||
"| `clear()` | — | `None` | resets engine |\n",
|
||||
"\n",
|
||||
"### Syntax rules\n",
|
||||
"\n",
|
||||
"| Item | Rule | Example |\n",
|
||||
"|------|------|---------|\n",
|
||||
"| Variable | Starts **uppercase** | `X`, `Role`, `Parent` |\n",
|
||||
"| Constant | All **lowercase** | `tom`, `admin`, `database` |\n",
|
||||
"| Query var | Prefix `?` | `?X`, `?Y`, `?Role` |\n",
|
||||
"| Rule body | `:-` separator, comma between atoms | `head(X) :- a(X, Z), b(Z, Y).` |\n",
|
||||
"\n",
|
||||
"### Class map\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"GraphBuilder.build() → kg dict {entities, relationships}\n",
|
||||
" ↓ kg[\"relationships\"] → dr.add_fact(rel)\n",
|
||||
" \n",
|
||||
"ContextGraph.add_node/add_edge → in-memory graph\n",
|
||||
" ↓ dr.load_from_graph(cg)\n",
|
||||
" \n",
|
||||
"DatalogReasoner.add_rule() → Horn clause rules\n",
|
||||
"DatalogReasoner.derive_all() → semi-naive fixpoint\n",
|
||||
"DatalogReasoner.query() → result rows\n",
|
||||
" ↓ build InferenceResult\n",
|
||||
" \n",
|
||||
"ExplanationGenerator → natural language justification\n",
|
||||
"GraphAnalyzer → graph structure metrics pre/post reasoning\n",
|
||||
"DatalogFact / DatalogRule → introspect engine state\n",
|
||||
"```"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.10.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,197 @@
|
||||
# Semantica Knowledge Explorer
|
||||
|
||||
A real-time visual interface for exploring knowledge graphs, decision intelligence, entity resolution, ontologies, and graph analytics built on top of the [Semantica](https://github.com/Hawksight-AI/semantica) library.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Minimum Version |
|
||||
|---|---|
|
||||
| Node.js | 18.x or higher (20.x recommended) |
|
||||
| npm | 9.x or higher |
|
||||
| Python | 3.8+ |
|
||||
| Semantica backend | running on `http://127.0.0.1:8000` |
|
||||
|
||||
Check your versions:
|
||||
|
||||
```bash
|
||||
node --version
|
||||
npm --version
|
||||
python --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Local Development)
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Hawksight-AI/semantica.git
|
||||
cd semantica
|
||||
```
|
||||
|
||||
### 2. Install the Semantica Python package
|
||||
|
||||
```bash
|
||||
pip install semantica
|
||||
```
|
||||
|
||||
Or install from source if you have the repo:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 3. Start the Semantica backend
|
||||
|
||||
The Explorer proxies all `/api` and `/ws` requests to `http://127.0.0.1:8000`. The backend must be running before you open the UI.
|
||||
|
||||
```bash
|
||||
# From the repo root
|
||||
python -m semantica.server
|
||||
```
|
||||
|
||||
The backend starts on port **8000** by default. Keep this terminal open.
|
||||
|
||||
### 4. Install frontend dependencies
|
||||
|
||||
Open a second terminal:
|
||||
|
||||
```bash
|
||||
cd explorer
|
||||
npm install
|
||||
```
|
||||
|
||||
> **Note:** This project uses Vite 5 and requires **Node 18+**. If you are on Node 16 or earlier, upgrade first.
|
||||
|
||||
### 5. Start the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite starts on **http://localhost:5173** by default. Open that URL in your browser.
|
||||
|
||||
---
|
||||
|
||||
## What you should see
|
||||
|
||||
The Explorer opens with a persistent left sidebar and six workspace tabs:
|
||||
|
||||
| Tab | What it shows |
|
||||
|---|---|
|
||||
| **Knowledge Graph** | Interactive Sigma.js canvas — nodes, edges, zoom, ForceAtlas2 layout |
|
||||
| **Timeline** | Temporal event scrubber over the graph |
|
||||
| **Decisions** | Causal chain viewer with outcome badges and decision filter |
|
||||
| **Registry** | Live audit log of every graph mutation (add-node, add-edge, etc.) |
|
||||
| **Entity Resolution** | Duplicate detection and entity merge workflow |
|
||||
| **KG Overview** | Aggregate stats, community breakdown, centrality heatmap |
|
||||
| **Ontology** | SKOS/OWL vocabulary hierarchy and schema summary |
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
explorer/
|
||||
├── src/
|
||||
│ ├── App.tsx # Root layout, tab routing, workspace wiring
|
||||
│ ├── index.css # Global resets, fonts, keyframe animations
|
||||
│ ├── store/
|
||||
│ │ └── registryStore.ts # Pub/sub audit registry (no external state lib)
|
||||
│ └── workspaces/
|
||||
│ ├── GraphWorkspace/ # Sigma.js graph canvas + inspector panel
|
||||
│ ├── DecisionWorkspace/ # Causal flow diagram + decision list
|
||||
│ ├── TimelineWorkspace/ # vis-timeline temporal scrubber
|
||||
│ ├── ManageWorkspace/ # Registry, KG Overview, Ontology tabs
|
||||
│ └── EnrichWorkspace/ # Entity resolution tab
|
||||
├── index.html
|
||||
├── vite.config.ts # Dev proxy → 127.0.0.1:8000, build → ../semantica/static
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available scripts
|
||||
|
||||
Run these from inside the `explorer/` directory:
|
||||
|
||||
```bash
|
||||
# Start the dev server with hot module replacement
|
||||
npm run dev
|
||||
|
||||
# Type-check and build a production bundle into ../semantica/static
|
||||
npm run build
|
||||
|
||||
# Preview the production build locally
|
||||
npm run preview
|
||||
|
||||
# Run ESLint over all source files
|
||||
npm run lint
|
||||
|
||||
# Run the graph store multi-edge unit tests
|
||||
npm run test:graph-store
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API & WebSocket proxy
|
||||
|
||||
During development, Vite forwards requests automatically — no CORS configuration needed:
|
||||
|
||||
| Pattern | Forwarded to |
|
||||
|---|---|
|
||||
| `/api/*` | `http://127.0.0.1:8000/api/*` |
|
||||
| `/ws` | `ws://127.0.0.1:8000/ws` |
|
||||
|
||||
If you run the backend on a different port, update `server.proxy` in [vite.config.ts](vite.config.ts).
|
||||
|
||||
---
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
cd explorer
|
||||
npm run build
|
||||
```
|
||||
|
||||
The compiled assets are written to `../semantica/static/`. The Semantica Python server serves this folder automatically at its root URL — no separate web server needed.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Blank graph / no data loads**
|
||||
- Make sure the Semantica backend is running (`python -m semantica.server`) before opening the UI.
|
||||
- Check the browser console for failed `/api/graph` requests — the proxy target may need updating in `vite.config.ts`.
|
||||
|
||||
**`npm install` fails or hangs**
|
||||
- Ensure you are using **Node 18 or 20**. Node 16 and Vite 5 are incompatible.
|
||||
- Delete `node_modules/` and `package-lock.json`, then re-run `npm install`.
|
||||
|
||||
**Port 5173 already in use**
|
||||
- Vite will automatically try the next available port and print it in the terminal. Use that URL instead.
|
||||
|
||||
**WebSocket not connecting (real-time mutations not appearing)**
|
||||
- Confirm the backend exposes a `/ws` WebSocket endpoint.
|
||||
- Check browser DevTools → Network → WS tab for the connection status.
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **React 19** + TypeScript (strict `noUnusedLocals`)
|
||||
- **Vite 5** with `babel-plugin-react-compiler`
|
||||
- **Sigma.js 3** + **Graphology** — graph rendering and in-memory graph store
|
||||
- **ForceAtlas2** — physics-based layout worker
|
||||
- **@tanstack/react-query** — data fetching for ontology and vocab tabs
|
||||
- **vis-timeline** — temporal event visualization
|
||||
- **lucide-react** — icon set
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See the root [CONTRIBUTING.md](../CONTRIBUTING.md) and open issues on the main [Semantica repository](https://github.com/Hawksight-AI/semantica).
|
||||
@@ -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 Knowledge Explorer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4665
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "semantica-knowledge-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",
|
||||
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts"
|
||||
},
|
||||
"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",
|
||||
"@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": "^4.3.0",
|
||||
"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",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
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,497 @@
|
||||
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 })));
|
||||
const RegistryTab = lazy(() => import('./workspaces/EnrichWorkspace/RegistryTab').then((module) => ({ default: module.RegistryTab })));
|
||||
const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/EntityResolutionTab').then((module) => ({ default: module.EntityResolutionTab })));
|
||||
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
|
||||
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
|
||||
|
||||
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type ExploreView = 'graph' | 'vocabulary';
|
||||
type AnalyzeView = 'sparql' | 'reasoning';
|
||||
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||
type ManageView = 'lineage' | 'kg-overview' | 'ontology';
|
||||
|
||||
type NavItem = {
|
||||
id: WorkspaceId;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: typeof Database;
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'explore', label: 'Knowledge Explorer', 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,
|
||||
kicker = 'Workspace',
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
tabs?: ReactNode;
|
||||
compact?: boolean;
|
||||
kicker?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="workspace-shell">
|
||||
<header className={`workspace-header${compact ? " workspace-header--compact" : ""}`}>
|
||||
<div className="workspace-header-main">
|
||||
<div className="workspace-kicker">{kicker}</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>;
|
||||
}
|
||||
|
||||
function WelcomeScreen() {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
color: 'var(--text-muted)',
|
||||
}}>
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700, color: 'var(--text-main)', letterSpacing: '-0.03em' }}>
|
||||
Welcome to Semantica
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: 14 }}>
|
||||
Select a workspace from the sidebar to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
|
||||
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
||||
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||
const [manageView, setManageView] = useState<ManageView>('lineage');
|
||||
|
||||
const renderWorkspace = () => {
|
||||
if (activeWorkspace === 'welcome') {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'explore') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Explore"
|
||||
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
|
||||
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
|
||||
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."
|
||||
kicker={analyzeView === 'reasoning' ? 'Reasoning Engine' : 'SPARQL Query'}
|
||||
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."
|
||||
kicker="Decision Intelligence"
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
<DecisionWorkspace />
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'enrich') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Enrich"
|
||||
subtitle="Import, export, reconcile, and audit graph entities."
|
||||
kicker="Knowledge Audit"
|
||||
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>
|
||||
<button className="workspace-tab" data-active={enrichView === 'resolve'} onClick={() => setEnrichView('resolve')}>
|
||||
Entity Resolution
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={enrichView === 'registry'} onClick={() => setEnrichView('registry')}>
|
||||
Registry
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
{enrichView === 'import' ? <ImportExportWorkspace /> :
|
||||
enrichView === 'merge' ? <DiffMergeWorkspace /> :
|
||||
enrichView === 'resolve' ? <EntityResolutionTab /> :
|
||||
<RegistryTab />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Manage"
|
||||
subtitle="Review provenance, lineage, ontology, and governance context."
|
||||
kicker="Graph Governance"
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={manageView === 'lineage'} onClick={() => setManageView('lineage')}>
|
||||
PROV-O Lineage
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={manageView === 'kg-overview'} onClick={() => setManageView('kg-overview')}>
|
||||
KG Overview
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={manageView === 'ontology'} onClick={() => setManageView('ontology')}>
|
||||
Ontology Summary
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
{manageView === 'lineage' ? <LineageDiagram /> :
|
||||
manageView === 'kg-overview' ? <KGOverviewTab /> :
|
||||
<OntologySummaryTab onOpenVocabularyBrowser={() => {
|
||||
setActiveWorkspace('explore');
|
||||
setExploreView('vocabulary');
|
||||
}} />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<style>{shellStyles}</style>
|
||||
<div className="app-shell">
|
||||
<aside className="app-rail">
|
||||
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
|
||||
{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,71 @@
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/* Skeleton pulse animation for loading placeholders */
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.45; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<App />,
|
||||
)
|
||||
Vendored
+2
@@ -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,178 @@
|
||||
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;
|
||||
communityId?: string;
|
||||
isCommunityGroup?: boolean;
|
||||
memberCount?: number;
|
||||
anchorNodeId?: string | null;
|
||||
|
||||
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;
|
||||
rawEdgeIds?: string[];
|
||||
isAggregated?: boolean;
|
||||
aggregateCount?: number;
|
||||
dominantEdgeType?: string;
|
||||
representativeWeight?: number;
|
||||
bundleKind?: "parallel" | "bidirectional" | "community";
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* src/store/registryStore.ts
|
||||
*
|
||||
* Lightweight client-side audit log for all KG / Ontology mutations.
|
||||
* No backend required — events are dispatched by each workspace after
|
||||
* a successful API call or WebSocket mutation.
|
||||
*
|
||||
* Any component can call logEvent() from anywhere (including non-React code).
|
||||
* React components subscribe via the useRegistry() hook.
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export type RegistryEntryOp =
|
||||
| "import"
|
||||
| "export"
|
||||
| "merge"
|
||||
| "add-node"
|
||||
| "add-edge"
|
||||
| "delete"
|
||||
| "infer"
|
||||
| "vocab-import";
|
||||
|
||||
export interface RegistryEntry {
|
||||
id: string;
|
||||
op: RegistryEntryOp;
|
||||
timestamp: Date;
|
||||
summary: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type Listener = (entries: readonly RegistryEntry[]) => void;
|
||||
|
||||
let _entries: RegistryEntry[] = [];
|
||||
const _listeners = new Set<Listener>();
|
||||
const MAX_ENTRIES = 500;
|
||||
|
||||
function _notify(): void {
|
||||
_listeners.forEach((fn) => fn(_entries));
|
||||
}
|
||||
|
||||
export function logEvent(
|
||||
op: RegistryEntryOp,
|
||||
summary: string,
|
||||
detail?: Record<string, unknown>,
|
||||
): void {
|
||||
const entry: RegistryEntry = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
op,
|
||||
timestamp: new Date(),
|
||||
summary,
|
||||
detail,
|
||||
};
|
||||
_entries = [entry, ..._entries].slice(0, MAX_ENTRIES);
|
||||
_notify();
|
||||
}
|
||||
|
||||
export function clearRegistry(): void {
|
||||
_entries = [];
|
||||
_notify();
|
||||
}
|
||||
|
||||
export function getRegistryEntries(): readonly RegistryEntry[] {
|
||||
return _entries;
|
||||
}
|
||||
|
||||
export function useRegistry(): readonly RegistryEntry[] {
|
||||
const [snapshot, setSnapshot] = useState<readonly RegistryEntry[]>(_entries);
|
||||
useEffect(() => {
|
||||
// Sync any events that arrived between render and subscribe
|
||||
setSnapshot(_entries);
|
||||
_listeners.add(setSnapshot);
|
||||
return () => {
|
||||
_listeners.delete(setSnapshot);
|
||||
};
|
||||
}, []);
|
||||
return snapshot;
|
||||
}
|
||||
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,407 @@
|
||||
/**
|
||||
* src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Scale, Search } 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);
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { opacity: 0.45; }
|
||||
50% { opacity: 0.85; }
|
||||
100% { opacity: 0.45; }
|
||||
}
|
||||
.skeleton-item {
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
`;
|
||||
|
||||
type OutcomeKind = "approved" | "rejected" | "deferred" | "pending" | string;
|
||||
|
||||
function outcomeStyle(outcome: string): { color: string; bg: string; border: string } {
|
||||
const lower = (outcome ?? "").toLowerCase();
|
||||
if (lower.includes("approv") || lower.includes("accept"))
|
||||
return { color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" };
|
||||
if (lower.includes("reject") || lower.includes("denied") || lower.includes("fail"))
|
||||
return { color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" };
|
||||
if (lower.includes("defer") || lower.includes("pending") || lower.includes("review"))
|
||||
return { color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" };
|
||||
return { color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" };
|
||||
}
|
||||
|
||||
function OutcomeBadge({ outcome }: { outcome: OutcomeKind }) {
|
||||
const style = outcomeStyle(outcome);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: style.color,
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
}}
|
||||
>
|
||||
{outcome || "unknown"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonList() {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-item" style={{ height: 62 }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Causal Flow Diagram ──────────────────────────────────────────── */
|
||||
|
||||
interface ChainStep {
|
||||
id: string;
|
||||
relationship: string;
|
||||
content?: string;
|
||||
type?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function RelationshipPill({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0, position: "relative", margin: "0 auto" }}>
|
||||
{/* Connector line top */}
|
||||
<div style={{ width: 2, height: 12, background: "rgba(88,166,255,0.25)" }} />
|
||||
{/* Pill */}
|
||||
<div
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: "#79c0ff",
|
||||
background: "rgba(88,166,255,0.1)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 260,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{/* Connector line bottom + arrow */}
|
||||
<div style={{ width: 2, height: 10, background: "rgba(88,166,255,0.25)" }} />
|
||||
<div style={{ width: 0, height: 0, borderLeft: "5px solid transparent", borderRight: "5px solid transparent", borderTop: "6px solid rgba(88,166,255,0.4)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainNodeCard({ step, index }: { step: ChainStep; index: number }) {
|
||||
const COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff"];
|
||||
const color = COLORS[index % COLORS.length];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
padding: "14px 16px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.5))",
|
||||
border: `1px solid ${color}33`,
|
||||
boxShadow: `0 0 0 1px ${color}11, inset 0 1px 0 rgba(255,255,255,0.04)`,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8, height: 8, borderRadius: "50%",
|
||||
background: color,
|
||||
boxShadow: `0 0 8px ${color}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{step.type ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", color,
|
||||
}}
|
||||
>
|
||||
{step.type}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 14, fontWeight: 600 }}>
|
||||
{step.content || step.id}
|
||||
</div>
|
||||
{step.id && step.id !== step.content ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", marginTop: 3 }}>{step.id}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CausalFlowDiagram({ chain, loading }: { chain: ChainStep[]; loading: boolean }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton-item" style={{ height: 68 }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chain.length === 0) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 24px", color: "#8b949e", fontSize: 13 }}>
|
||||
No causal chain steps found for this decision.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "stretch" }}>
|
||||
{chain.map((step, index) => (
|
||||
<div key={`${step.id}-${index}`} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<ChainNodeCard step={step} index={index} />
|
||||
{index < chain.length - 1 ? (
|
||||
<RelationshipPill label={chain[index + 1]?.relationship || "→"} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Workspace ──────────────────────────────────────────────── */
|
||||
|
||||
export function DecisionWorkspace() {
|
||||
const [decisions, setDecisions] = useState<any[]>([]);
|
||||
const [selectedDecision, setSelectedDecision] = useState<any | null>(null);
|
||||
const [chain, setChain] = useState<ChainStep[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setListLoading(true);
|
||||
fetch("/api/decisions", { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`Failed to load decisions: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setDecisions(data);
|
||||
if (data.length > 0) void handleSelectDecision(data[0]);
|
||||
})
|
||||
.catch((err) => { if (err.name !== "AbortError") console.error(err); })
|
||||
.finally(() => setListLoading(false));
|
||||
return () => controller.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filteredDecisions = useMemo(() => {
|
||||
if (!filterQuery.trim()) return decisions;
|
||||
const q = filterQuery.toLowerCase();
|
||||
return decisions.filter(
|
||||
(d) =>
|
||||
String(d.decision_id ?? "").toLowerCase().includes(q) ||
|
||||
String(d.category ?? "").toLowerCase().includes(q) ||
|
||||
String(d.outcome ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [decisions, filterQuery]);
|
||||
|
||||
const handleSelectDecision = async (d: any) => {
|
||||
setSelectedDecision(d);
|
||||
setLoading(true);
|
||||
setChain([]);
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const res = await fetch(`/api/decisions/${encodeURIComponent(d.decision_id)}/chain`, { signal: controller.signal });
|
||||
if (!res.ok) throw new Error(`Failed to load chain: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setChain(data.chain || []);
|
||||
} catch (e) {
|
||||
if ((e as DOMException).name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return () => controller.abort();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: "100%", background: "#0d1117", overflow: "hidden" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Left Column — Decision List */}
|
||||
<div
|
||||
className="glass-panel"
|
||||
style={{
|
||||
width: 300,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 0,
|
||||
border: "none",
|
||||
borderRight: "1px solid rgba(88,166,255,0.16)",
|
||||
}}
|
||||
>
|
||||
{/* List header */}
|
||||
<div style={{ padding: "20px 20px 14px", borderBottom: "1px solid rgba(255,255,255,0.06)", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<Scale size={16} color="#4aa3ff" />
|
||||
<h2 style={{ color: "#ebf3ff", margin: 0, fontSize: 15, fontWeight: 700 }}>Decisions</h2>
|
||||
{decisions.length > 0 ? (
|
||||
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>{decisions.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Filter input */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<Search
|
||||
size={13}
|
||||
color="#8b949e"
|
||||
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter decisions…"
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
style={filterInputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
|
||||
{listLoading ? (
|
||||
<SkeletonList />
|
||||
) : filteredDecisions.length === 0 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 13, textAlign: "center", padding: "32px 12px" }}>
|
||||
{decisions.length === 0 ? "No decisions available." : "No decisions match your filter."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{filteredDecisions.map((d) => {
|
||||
const isActive = selectedDecision?.decision_id === d.decision_id;
|
||||
return (
|
||||
<button
|
||||
key={d.decision_id}
|
||||
onClick={() => void handleSelectDecision(d)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
background: isActive
|
||||
? "rgba(74,163,255,0.15)"
|
||||
: "rgba(255,255,255,0.02)",
|
||||
border: isActive
|
||||
? "1px solid rgba(74,163,255,0.32)"
|
||||
: "1px solid rgba(255,255,255,0.06)",
|
||||
color: isActive ? "#ffffff" : "#c6d4e3",
|
||||
transition: "all 160ms ease",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>{d.decision_id}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||
{d.category ? (
|
||||
<span style={{ fontSize: 11, color: "#8b949e" }}>{d.category}</span>
|
||||
) : null}
|
||||
{d.outcome ? <OutcomeBadge outcome={d.outcome} /> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column — Decision Detail */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Radial accent */}
|
||||
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse at top right, rgba(88,166,255,0.04), transparent 55%)", pointerEvents: "none", zIndex: 0 }} />
|
||||
|
||||
{selectedDecision ? (
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px", position: "relative", zIndex: 1 }}>
|
||||
{/* Decision header */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: 6 }}>
|
||||
Decision ID
|
||||
</div>
|
||||
<h1 style={{ color: "#ffffff", fontSize: 24, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 8px 0", wordBreak: "break-word" }}>
|
||||
{selectedDecision.decision_id}
|
||||
</h1>
|
||||
</div>
|
||||
{selectedDecision.outcome ? <OutcomeBadge outcome={selectedDecision.outcome} /> : null}
|
||||
</div>
|
||||
|
||||
{selectedDecision.category ? (
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 10px", borderRadius: 999, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)", color: "#8b949e", fontSize: 12 }}>
|
||||
{selectedDecision.category}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Causal Chain */}
|
||||
<div className="glass-panel" style={{ padding: 24, borderRadius: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 20 }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "linear-gradient(135deg, #4aa3ff, #f2b66d)", boxShadow: "0 0 10px rgba(74,163,255,0.4)" }} />
|
||||
<h3 style={{ color: "#e6edf3", margin: 0, fontSize: 14, fontWeight: 700, letterSpacing: "0.02em" }}>
|
||||
Causal Chain
|
||||
</h3>
|
||||
{chain.length > 0 && !loading ? (
|
||||
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>
|
||||
{chain.length} step{chain.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<CausalFlowDiagram chain={chain} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: 14 }}>
|
||||
Select a decision to inspect its causal chain.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const filterInputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "7px 10px 7px 30px",
|
||||
background: "rgba(0,0,0,0.25)",
|
||||
border: "1px solid rgba(88,166,255,0.16)",
|
||||
borderRadius: 8,
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
|
||||
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}`);
|
||||
logEvent("merge", `Merged ${duplicateId} → ${data.merged_into} · ${data.edges_updated} edges redirected`, {
|
||||
primary: data.merged_into,
|
||||
duplicate: duplicateId,
|
||||
edgesUpdated: data.edges_updated,
|
||||
});
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx
|
||||
*
|
||||
* Entity Resolution — run duplicate detection, review flagged pairs,
|
||||
* perform one-click merges, and view merge history from the Registry.
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { ScanSearch, GitMerge, X, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { logEvent, useRegistry } from "../../store/registryStore";
|
||||
|
||||
interface DedupPair {
|
||||
a: { id: string; label: string; type: string };
|
||||
b: { id: string; label: string; type: string };
|
||||
score: number;
|
||||
dismissed?: boolean;
|
||||
}
|
||||
|
||||
interface RawDuplicateItem {
|
||||
entity_a?: string | Record<string, unknown>;
|
||||
entity_b?: string | Record<string, unknown>;
|
||||
similarity?: number;
|
||||
score?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function extractId(entity: string | Record<string, unknown> | undefined): string {
|
||||
if (!entity) return "";
|
||||
if (typeof entity === "string") return entity;
|
||||
return String(entity.id ?? entity.text ?? JSON.stringify(entity));
|
||||
}
|
||||
|
||||
function extractLabel(entity: string | Record<string, unknown> | undefined): string {
|
||||
if (!entity) return "";
|
||||
if (typeof entity === "string") return entity;
|
||||
return String(entity.text ?? entity.label ?? entity.content ?? entity.id ?? "");
|
||||
}
|
||||
|
||||
function extractType(entity: string | Record<string, unknown> | undefined): string {
|
||||
if (!entity || typeof entity === "string") return "entity";
|
||||
return String(entity.type ?? "entity");
|
||||
}
|
||||
|
||||
function parseDuplicates(raw: RawDuplicateItem[]): DedupPair[] {
|
||||
return raw.map((item) => ({
|
||||
a: {
|
||||
id: extractId(item.entity_a as string | Record<string, unknown>),
|
||||
label: extractLabel(item.entity_a as string | Record<string, unknown>),
|
||||
type: extractType(item.entity_a as string | Record<string, unknown>),
|
||||
},
|
||||
b: {
|
||||
id: extractId(item.entity_b as string | Record<string, unknown>),
|
||||
label: extractLabel(item.entity_b as string | Record<string, unknown>),
|
||||
type: extractType(item.entity_b as string | Record<string, unknown>),
|
||||
},
|
||||
score: Number(item.similarity ?? item.score ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function ScoreBar({ score }: { score: number }) {
|
||||
const pct = Math.min(100, Math.round(score * 100));
|
||||
const color = score >= 0.9 ? "#ff7b72" : score >= 0.75 ? "#f2b66d" : "#4cc38a";
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ flex: 1, height: 4, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" }}>
|
||||
<div style={{ width: `${pct}%`, height: "100%", borderRadius: 999, background: color, transition: "width 300ms ease" }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color, minWidth: 34, textAlign: "right" }}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PairRow({
|
||||
pair,
|
||||
onMerge,
|
||||
onDismiss,
|
||||
}: {
|
||||
pair: DedupPair;
|
||||
onMerge: (primaryId: string, duplicateId: string) => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [merging, setMerging] = useState(false);
|
||||
|
||||
const handleMerge = async () => {
|
||||
setMerging(true);
|
||||
await onMerge(pair.a.id, pair.b.id);
|
||||
setMerging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pairCardStyle}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
{/* Expand */}
|
||||
<button onClick={() => setExpanded((v) => !v)} style={iconBtnStyle}>
|
||||
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
</button>
|
||||
|
||||
{/* Entity Labels */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={entityChipStyle}>{pair.a.label || pair.a.id}</span>
|
||||
<span style={{ color: "#f2b66d", fontSize: 12, fontWeight: 700 }}>≈</span>
|
||||
<span style={entityChipStyle}>{pair.b.label || pair.b.id}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ScoreBar score={pair.score} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => void handleMerge()}
|
||||
disabled={merging}
|
||||
style={{
|
||||
...actionBtnStyle,
|
||||
background: "rgba(76,195,138,0.12)",
|
||||
border: "1px solid rgba(76,195,138,0.28)",
|
||||
color: "#4cc38a",
|
||||
}}
|
||||
>
|
||||
{merging ? <Loader2 size={12} className="animate-spin" /> : <GitMerge size={12} />}
|
||||
<span>Merge</span>
|
||||
</button>
|
||||
<button onClick={onDismiss} style={iconBtnStyle} title="Dismiss">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded diff */}
|
||||
{expanded ? (
|
||||
<div style={{ marginTop: 12, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
{[
|
||||
{ label: "Primary (keep)", entity: pair.a, accentColor: "#4aa3ff" },
|
||||
{ label: "Duplicate (remove)", entity: pair.b, accentColor: "#ff7b72" },
|
||||
].map(({ label, entity, accentColor }) => (
|
||||
<div key={entity.id} style={{ ...diffCardStyle, borderColor: `${accentColor}33` }}>
|
||||
<div style={{ color: accentColor, fontSize: 10, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600 }}>{entity.label || entity.id}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, marginTop: 3 }}>{entity.type}</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, marginTop: 4, fontFamily: "monospace" }}>{entity.id}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityResolutionTab() {
|
||||
const [threshold, setThreshold] = useState(0.82);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [pairs, setPairs] = useState<DedupPair[]>([]);
|
||||
const [scanError, setScanError] = useState("");
|
||||
const registryEntries = useRegistry();
|
||||
|
||||
const mergeHistory = registryEntries.filter((e) => e.op === "merge");
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
setScanning(true);
|
||||
setScanError("");
|
||||
try {
|
||||
const res = await fetch("/api/enrich/dedup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ threshold }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as Record<string, string>).detail ?? `Scan failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const rawDuplicates: RawDuplicateItem[] = Array.isArray(data.duplicates)
|
||||
? (data.duplicates as RawDuplicateItem[])
|
||||
: [];
|
||||
const parsed = parseDuplicates(rawDuplicates);
|
||||
setPairs(parsed);
|
||||
logEvent("import", `Dedup scan found ${parsed.length} flagged pair${parsed.length !== 1 ? "s" : ""} (threshold ${threshold.toFixed(2)})`, {
|
||||
threshold,
|
||||
flagged: parsed.length,
|
||||
});
|
||||
} catch (err) {
|
||||
setScanError(err instanceof Error ? err.message : "Scan failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, [threshold]);
|
||||
|
||||
const handleMerge = useCallback(async (primaryId: string, duplicateId: string) => {
|
||||
try {
|
||||
const res = await fetch("/api/enrich/merge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Merge failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
logEvent("merge", `Merged ${duplicateId} → ${primaryId} · ${data.edges_updated ?? 0} edges redirected`, {
|
||||
primary: primaryId,
|
||||
duplicate: duplicateId,
|
||||
edgesUpdated: data.edges_updated,
|
||||
});
|
||||
setPairs((prev) => prev.filter((p) => !(p.a.id === primaryId && p.b.id === duplicateId)));
|
||||
} catch (err) {
|
||||
console.error("[EntityResolution] merge failed", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback((index: number) => {
|
||||
setPairs((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ScanSearch size={18} color="#f2b66d" />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Entity Resolution</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>Detect and merge duplicate entities in the knowledge graph</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan controls */}
|
||||
<div style={controlsCardStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: 1, minWidth: 240 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<label style={{ color: "#c6d4e3", fontSize: 12, fontWeight: 600 }}>Similarity Threshold</label>
|
||||
<span style={{ color: "#f2b66d", fontSize: 12, fontWeight: 700 }}>{threshold.toFixed(2)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={0.99}
|
||||
step={0.01}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(parseFloat(e.target.value))}
|
||||
style={{ width: "100%", accentColor: "#f2b66d", cursor: "pointer" }}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6a7f97", fontSize: 10, marginTop: 2 }}>
|
||||
<span>More results (0.50)</span>
|
||||
<span>Fewer, higher confidence (0.99)</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleScan()}
|
||||
disabled={scanning}
|
||||
style={scanBtnStyle}
|
||||
>
|
||||
{scanning ? <Loader2 size={14} className="animate-spin" /> : <ScanSearch size={14} />}
|
||||
<span>{scanning ? "Scanning…" : "Run Dedup Scan"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{scanError ? (
|
||||
<div style={{ color: "#ff7b72", fontSize: 12, marginTop: 8 }}>{scanError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", gap: 0 }}>
|
||||
{/* Flagged pairs */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "16px 24px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{pairs.length > 0 ? (
|
||||
<>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, fontWeight: 600 }}>
|
||||
{pairs.length} flagged pair{pairs.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
<button onClick={() => setPairs([])} style={clearAllBtnStyle}>Clear all</button>
|
||||
</div>
|
||||
{pairs.map((pair, index) => (
|
||||
<PairRow
|
||||
key={`${pair.a.id}:${pair.b.id}`}
|
||||
pair={pair}
|
||||
onMerge={handleMerge}
|
||||
onDismiss={() => handleDismiss(index)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div style={emptyStateStyle}>
|
||||
<ScanSearch size={36} color="rgba(242,182,109,0.15)" />
|
||||
<div style={{ color: "#8b949e", fontSize: 14, marginTop: 12, fontWeight: 500 }}>
|
||||
No flagged pairs
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 280 }}>
|
||||
Set a similarity threshold and run a dedup scan to detect potential duplicates.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Merge history sidebar */}
|
||||
{mergeHistory.length > 0 ? (
|
||||
<div style={historyPanelStyle}>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 10 }}>
|
||||
Merge History
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{mergeHistory.map((entry) => (
|
||||
<div key={entry.id} style={historyRowStyle}>
|
||||
<GitMerge size={11} color="#f2b66d" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#c6d4e3", fontSize: 11, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{entry.summary}
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10 }}>
|
||||
{entry.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0d1117",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: "20px 24px 16px",
|
||||
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const controlsCardStyle: React.CSSProperties = {
|
||||
margin: "16px 24px",
|
||||
padding: "16px 20px",
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6))",
|
||||
border: "1px solid rgba(242,182,109,0.18)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const scanBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "10px 18px",
|
||||
borderRadius: 10,
|
||||
background: "linear-gradient(135deg, rgba(242,182,109,0.22), rgba(242,182,109,0.1))",
|
||||
border: "1px solid rgba(242,182,109,0.32)",
|
||||
color: "#f2b66d",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const pairCardStyle: React.CSSProperties = {
|
||||
padding: "12px 14px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.6), rgba(22,27,34,0.4))",
|
||||
border: "1px solid rgba(255,255,255,0.07)",
|
||||
};
|
||||
|
||||
const entityChipStyle: React.CSSProperties = {
|
||||
display: "inline-block",
|
||||
padding: "4px 10px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "#e6edf3",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const actionBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const iconBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8b949e",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const diffCardStyle: React.CSSProperties = {
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
background: "rgba(0,0,0,0.2)",
|
||||
border: "1px solid transparent",
|
||||
};
|
||||
|
||||
const historyPanelStyle: React.CSSProperties = {
|
||||
width: 240,
|
||||
borderLeft: "1px solid rgba(255,255,255,0.06)",
|
||||
padding: "16px 16px",
|
||||
overflowY: "auto",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const historyRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 7,
|
||||
padding: "8px 0",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.04)",
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 40,
|
||||
minHeight: 200,
|
||||
};
|
||||
|
||||
const clearAllBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 6,
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* src/workspaces/EnrichWorkspace/RegistryTab.tsx
|
||||
*
|
||||
* Document Registry — a live, filterable chronological audit log of every
|
||||
* KG / Ontology mutation that occurred in this session.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { ClipboardList, Filter, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useRegistry, clearRegistry, type RegistryEntryOp } from "../../store/registryStore";
|
||||
|
||||
const OP_META: Record<
|
||||
RegistryEntryOp,
|
||||
{ label: string; color: string; bg: string; border: string }
|
||||
> = {
|
||||
import: { label: "IMPORT", color: "#4aa3ff", bg: "rgba(74,163,255,0.12)", border: "rgba(74,163,255,0.28)" },
|
||||
export: { label: "EXPORT", color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" },
|
||||
merge: { label: "MERGE", color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" },
|
||||
"add-node": { label: "ADD NODE", color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" },
|
||||
"add-edge": { label: "ADD EDGE", color: "#4cc38a", bg: "rgba(76,195,138,0.10)", border: "rgba(76,195,138,0.22)" },
|
||||
delete: { label: "DELETE", color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" },
|
||||
infer: { label: "INFER", color: "#d2a8ff", bg: "rgba(210,168,255,0.12)", border: "rgba(210,168,255,0.28)" },
|
||||
"vocab-import": { label: "VOCAB", color: "#79c0ff", bg: "rgba(121,192,255,0.12)", border: "rgba(121,192,255,0.28)" },
|
||||
};
|
||||
|
||||
const ALL_OPS: (RegistryEntryOp | "all")[] = [
|
||||
"all", "import", "export", "merge", "add-node", "add-edge", "infer", "delete", "vocab-import",
|
||||
];
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function EntryRow({ entry }: { entry: ReturnType<typeof useRegistry>[number] }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const meta = OP_META[entry.op];
|
||||
const hasDetail = entry.detail && Object.keys(entry.detail).length > 0;
|
||||
|
||||
return (
|
||||
<div style={entryCardStyle}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
{/* Op Badge */}
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: "inline-block",
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.07em",
|
||||
color: meta.color,
|
||||
background: meta.bg,
|
||||
border: `1px solid ${meta.border}`,
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 500, wordBreak: "break-word" }}>
|
||||
{entry.summary}
|
||||
</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, marginTop: 3 }}>
|
||||
{formatDate(entry.timestamp)} · {formatTimestamp(entry.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand toggle */}
|
||||
{hasDetail ? (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
title={expanded ? "Collapse details" : "Expand details"}
|
||||
style={expandBtnStyle}
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && hasDetail ? (
|
||||
<pre style={detailPreStyle}>
|
||||
{JSON.stringify(entry.detail, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegistryTab() {
|
||||
const entries = useRegistry();
|
||||
const [activeFilter, setActiveFilter] = useState<RegistryEntryOp | "all">("all");
|
||||
|
||||
const filtered = activeFilter === "all"
|
||||
? entries
|
||||
: entries.filter((e) => e.op === activeFilter);
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ClipboardList size={18} color="#4aa3ff" />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Document Registry</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>
|
||||
Audit log of all KG and Ontology mutations this session
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ color: "#8fa8c6", fontSize: 12 }}>
|
||||
{entries.length} event{entries.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{entries.length > 0 ? (
|
||||
<button
|
||||
onClick={clearRegistry}
|
||||
title="Clear all events"
|
||||
style={clearBtnStyle}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div style={filterBarStyle}>
|
||||
<Filter size={13} color="#8fa8c6" />
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{ALL_OPS.map((op) => {
|
||||
const isActive = op === activeFilter;
|
||||
const meta = op === "all" ? null : OP_META[op as RegistryEntryOp];
|
||||
return (
|
||||
<button
|
||||
key={op}
|
||||
onClick={() => setActiveFilter(op as typeof activeFilter)}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
border: isActive
|
||||
? `1px solid ${meta?.border ?? "rgba(127,208,255,0.35)"}`
|
||||
: "1px solid rgba(255,255,255,0.06)",
|
||||
background: isActive
|
||||
? (meta?.bg ?? "rgba(74,163,255,0.14)")
|
||||
: "transparent",
|
||||
color: isActive
|
||||
? (meta?.color ?? "#8ed3ff")
|
||||
: "#8b949e",
|
||||
transition: "all 140ms ease",
|
||||
}}
|
||||
>
|
||||
{op === "all" ? "All" : (meta?.label ?? op)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div style={feedStyle}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={emptyStateStyle}>
|
||||
<ClipboardList size={36} color="rgba(127,208,255,0.15)" />
|
||||
<div style={{ color: "#8b949e", fontSize: 14, marginTop: 12, fontWeight: 500 }}>
|
||||
No events recorded yet
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
|
||||
Import a file, run reasoning, or merge entities to see activity appear here.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((entry) => <EntryRow key={entry.id} entry={entry} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0d1117",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "20px 24px 16px",
|
||||
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const filterBarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "12px 24px",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.05)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const feedStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "16px 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const entryCardStyle: React.CSSProperties = {
|
||||
padding: "12px 14px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.6), rgba(22,27,34,0.4))",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||
};
|
||||
|
||||
const expandBtnStyle: React.CSSProperties = {
|
||||
flexShrink: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8b949e",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const detailPreStyle: React.CSSProperties = {
|
||||
marginTop: 10,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(0,0,0,0.28)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
color: "#79c0ff",
|
||||
fontSize: 11,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
overflowX: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
};
|
||||
|
||||
const clearBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,123,114,0.22)",
|
||||
background: "rgba(255,123,114,0.06)",
|
||||
color: "#ff7b72",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 40,
|
||||
minHeight: 280,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,569 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import type { GraphSelectedNodeKind } from "./types";
|
||||
|
||||
export type LinkPrediction = {
|
||||
target: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type PathResponse = {
|
||||
path: string[];
|
||||
edge_ids?: string[];
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
};
|
||||
|
||||
export interface GraphInspectorPanelProps {
|
||||
nodeId: string;
|
||||
inspectableNodeId?: string | null;
|
||||
selectedNodeKind?: GraphSelectedNodeKind;
|
||||
canActivateFocused?: boolean;
|
||||
focusedUnavailableReason?: string | null;
|
||||
predictions: LinkPrediction[];
|
||||
predictionType: string;
|
||||
onPredictionTypeChange: (value: string) => void;
|
||||
onRunPredictions: () => void;
|
||||
isRunningPredictions?: boolean;
|
||||
pathTargetId: string;
|
||||
onPathTargetChange: (value: string) => void;
|
||||
onTracePath: () => void;
|
||||
pathResult: PathResponse | null;
|
||||
onDownloadProvenance: (format: "json" | "markdown") => void;
|
||||
onFocusNode?: (nodeId: string) => 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] }));
|
||||
}
|
||||
|
||||
/* ─── Path Flow Visualizer ──────────────────────────────────────── */
|
||||
|
||||
function getNodeLabel(nodeId: string): string {
|
||||
if (!graph.hasNode(nodeId)) return nodeId;
|
||||
const attrs = graph.getNodeAttributes(nodeId) as { label?: string; content?: string };
|
||||
return String(attrs.label ?? attrs.content ?? nodeId);
|
||||
}
|
||||
|
||||
function getEdgeLabelBetween(sourceId: string, targetId: string, edgeIds?: string[]): string {
|
||||
// Try to find the specific edge from edgeIds first
|
||||
if (edgeIds) {
|
||||
for (const edgeId of edgeIds) {
|
||||
if (graph.hasEdge(edgeId)) {
|
||||
const [src, tgt] = graph.extremities(edgeId);
|
||||
if ((src === sourceId && tgt === targetId) || (src === targetId && tgt === sourceId)) {
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as { edgeType?: string };
|
||||
return attrs.edgeType ?? "→";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: find any edge between the pair
|
||||
if (graph.hasNode(sourceId) && graph.hasNode(targetId)) {
|
||||
let label = "→";
|
||||
graph.forEachEdge(sourceId, targetId, (_edgeId, attrs) => {
|
||||
const edgeAttrs = attrs as { edgeType?: string };
|
||||
if (edgeAttrs.edgeType) label = edgeAttrs.edgeType;
|
||||
});
|
||||
return label;
|
||||
}
|
||||
return "→";
|
||||
}
|
||||
|
||||
function PathFlowViz({
|
||||
path,
|
||||
edgeIds,
|
||||
totalWeight,
|
||||
onFocusNode,
|
||||
}: {
|
||||
path: string[];
|
||||
edgeIds?: string[];
|
||||
totalWeight: number;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}) {
|
||||
if (path.length === 0) {
|
||||
return <div style={emptyTextStyle}>No path found between the selected nodes.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Horizontal scrollable chip flow */}
|
||||
<div style={pathFlowContainerStyle}>
|
||||
{path.map((nodeId, index) => {
|
||||
const label = getNodeLabel(nodeId);
|
||||
const edgeLabel =
|
||||
index < path.length - 1
|
||||
? getEdgeLabelBetween(nodeId, path[index + 1], edgeIds)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={`${nodeId}-${index}`} style={{ display: "contents" }}>
|
||||
{/* Node chip */}
|
||||
<button
|
||||
onClick={() => onFocusNode?.(nodeId)}
|
||||
title={`Focus: ${nodeId}`}
|
||||
style={{
|
||||
...pathNodeChipStyle,
|
||||
cursor: onFocusNode ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
<span style={pathNodeIndexStyle}>{index + 1}</span>
|
||||
<span style={{ maxWidth: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Edge connector */}
|
||||
{edgeLabel !== null ? (
|
||||
<div style={pathEdgeConnectorStyle}>
|
||||
<div style={{ width: 16, height: 1, background: "rgba(88,166,255,0.3)" }} />
|
||||
<span style={pathEdgeLabelStyle}>{edgeLabel}</span>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 12, height: 1, background: "rgba(88,166,255,0.3)" }} />
|
||||
<div style={{ width: 0, height: 0, borderTop: "4px solid transparent", borderBottom: "4px solid transparent", borderLeft: "5px solid rgba(88,166,255,0.4)" }} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Weight badge */}
|
||||
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>Total weight:</span>
|
||||
<span style={{ color: "#79c0ff", fontSize: 12, fontWeight: 700 }}>{totalWeight.toFixed(3)}</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>·</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>{path.length} hops</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Panel ─────────────────────────────────────────────────── */
|
||||
|
||||
export function GraphInspectorPanel({
|
||||
nodeId,
|
||||
inspectableNodeId,
|
||||
selectedNodeKind = "none",
|
||||
canActivateFocused = false,
|
||||
focusedUnavailableReason = null,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
onRunPredictions,
|
||||
isRunningPredictions = false,
|
||||
pathTargetId,
|
||||
onPathTargetChange,
|
||||
onTracePath,
|
||||
pathResult,
|
||||
onDownloadProvenance,
|
||||
onFocusNode,
|
||||
}: GraphInspectorPanelProps) {
|
||||
if (!nodeId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", gap: 12, marginTop: 32 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(74,163,255,0.08)", border: "1px solid rgba(74,163,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", background: "rgba(127,208,255,0.3)" }} />
|
||||
</div>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0, lineHeight: 1.6 }}>
|
||||
Search for a node or click one in the canvas to inspect its properties.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedNodeId = inspectableNodeId && graph.hasNode(inspectableNodeId) ? inspectableNodeId : null;
|
||||
const directlyInspectable = graph.hasNode(nodeId);
|
||||
const effectiveNodeId = directlyInspectable ? nodeId : resolvedNodeId;
|
||||
const actionNodeId = directlyInspectable ? nodeId : resolvedNodeId;
|
||||
const groupedDisplaySelection = selectedNodeKind === "grouped" && !directlyInspectable;
|
||||
|
||||
if (!effectiveNodeId) {
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<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: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{nodeId}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
</div>
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? "Activate Focused mode to resolve this grouped selection to its canonical node."
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const attributes = graph.getNodeAttributes(effectiveNodeId) 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 }}>
|
||||
{/* Node identity */}
|
||||
<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 }}>
|
||||
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? effectiveNodeId)}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{groupedDisplaySelection ? nodeId : effectiveNodeId}
|
||||
</div>
|
||||
{groupedDisplaySelection ? (
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? `Canonical node available: ${effectiveNodeId}`
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
{/* Temporal bounds */}
|
||||
{(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>
|
||||
) : null}
|
||||
|
||||
{/* Actions */}
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Actions</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<button
|
||||
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
||||
onClick={onRunPredictions}
|
||||
disabled={isRunningPredictions || !actionNodeId}
|
||||
>
|
||||
{isRunningPredictions ? (
|
||||
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
||||
) : null}
|
||||
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
|
||||
Provenance JSON
|
||||
</button>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
|
||||
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>
|
||||
|
||||
{/* Trace Path */}
|
||||
<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} disabled={!actionNodeId}>Trace Causal Path</button>
|
||||
|
||||
{pathResult?.path?.length ? (
|
||||
<PathFlowViz
|
||||
path={pathResult.path}
|
||||
edgeIds={pathResult.edge_ids}
|
||||
totalWeight={pathResult.total_weight}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>
|
||||
Choose a target or click a candidate prediction to prepare a path trace.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Candidate Links */}
|
||||
<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={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: "rgba(88,166,255,0.12)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#58a6ff",
|
||||
}}>
|
||||
{(prediction.score * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : isRunningPredictions ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: 8, color: "#8b949e", fontSize: 12 }}>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
<span>Computing candidate links…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Run link prediction to surface likely next-hop relationships.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Source Attribution */}
|
||||
<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>
|
||||
|
||||
{/* Properties */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
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 groupedSelectionNoticeStyle: CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88,166,255,0.08)",
|
||||
border: "1px solid rgba(88,166,255,0.2)",
|
||||
borderRadius: 12,
|
||||
};
|
||||
|
||||
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: "10px 12px",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.12)",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
const pathFlowContainerStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
flexWrap: "wrap",
|
||||
rowGap: 8,
|
||||
};
|
||||
|
||||
const pathNodeChipStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(88,166,255,0.1)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#e6edf3",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
maxWidth: 160,
|
||||
};
|
||||
|
||||
const pathNodeIndexStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(88,166,255,0.22)",
|
||||
color: "#79c0ff",
|
||||
fontSize: 9,
|
||||
fontWeight: 800,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const pathEdgeConnectorStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const pathEdgeLabelStyle: CSSProperties = {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: "#6a7f97",
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
maxWidth: 70,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
@@ -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,479 @@
|
||||
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 { resolveDisplayGraph } from "./graphSceneState";
|
||||
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 EMPTY_PATH: string[] = [];
|
||||
|
||||
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,
|
||||
visibleNeighborCount: graph.neighbors(nodeId).length,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
|
||||
};
|
||||
}
|
||||
|
||||
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 displayResult = useMemo(
|
||||
() => resolveDisplayGraph(selectedNodeId, activePath, EMPTY_PATH, viewMode, { aggregationEnabled: true }),
|
||||
[activePath, graphVersion, selectedNodeId, viewMode],
|
||||
);
|
||||
|
||||
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}
|
||||
graphVersion={graphVersion}
|
||||
graphReady={Boolean(snapshot)}
|
||||
displayGraph={displayResult.graph}
|
||||
displayMeta={displayResult.meta}
|
||||
displayState={displayResult.state}
|
||||
selectedEdgeId=""
|
||||
selectedNodeId={selectedNodeId}
|
||||
focusedNodeId={viewMode === "focused" ? selectedNodeId : ""}
|
||||
activePath={activePath}
|
||||
activePathEdgeIds={EMPTY_PATH}
|
||||
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,844 @@
|
||||
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;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
};
|
||||
|
||||
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,
|
||||
visibleNeighborCount: neighborCount,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: neighborCount > 8,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
visibleNeighborCount: 0,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: false,
|
||||
}
|
||||
: 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 requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
|
||||
if (nextViewMode === "focused") {
|
||||
if (!selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
setViewMode("focused");
|
||||
setIsLayoutRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewMode("full");
|
||||
}, [selectedNodeId]);
|
||||
|
||||
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={() => requestViewMode("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={() => requestViewMode("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,55 @@
|
||||
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),
|
||||
zoomIn: () => canvasRef.current?.zoomIn(),
|
||||
zoomOut: () => canvasRef.current?.zoomOut(),
|
||||
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,25 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export const focusCameraBehavior: GraphBehavior = {
|
||||
id: "focus-camera",
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
performAction: (context, action) => {
|
||||
if (action.type === "focusNode") {
|
||||
context.focusNodeInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action.type === "centerSelection") {
|
||||
context.centerSelectionInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action.type === "centerGroupedSelection") {
|
||||
context.centerGroupedSelectionInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -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,36 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createSearchFocusBehavior(): GraphBehavior {
|
||||
let lastSelectedNodeId = "";
|
||||
let lastViewMode = "";
|
||||
|
||||
return {
|
||||
id: "search-focus",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastSelectedNodeId = "";
|
||||
lastViewMode = "";
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextSelectedNodeId = interactionState.selectedNodeId;
|
||||
const nextViewMode = interactionState.viewMode;
|
||||
if (nextViewMode !== lastViewMode) {
|
||||
lastViewMode = nextViewMode;
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
return;
|
||||
}
|
||||
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
lastViewMode = nextViewMode;
|
||||
return;
|
||||
}
|
||||
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
lastViewMode = nextViewMode;
|
||||
context.dispatchAction({
|
||||
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
|
||||
nodeId: nextSelectedNodeId,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 }
|
||||
| { type: "centerSelection"; nodeId: string }
|
||||
| { type: "centerGroupedSelection"; 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;
|
||||
centerSelectionInView: (nodeId: string) => void;
|
||||
centerGroupedSelectionInView: (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,29 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
import type { GraphViewMode } from "../types";
|
||||
|
||||
export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
let lastViewMode: GraphViewMode | null = null;
|
||||
|
||||
return {
|
||||
id: "view-mode-switch",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastViewMode = null;
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
if (interactionState.viewMode === lastViewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastViewMode = interactionState.viewMode;
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
|
||||
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,705 @@
|
||||
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";
|
||||
const IS_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV);
|
||||
|
||||
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;
|
||||
};
|
||||
grouped: {
|
||||
initialLayout: {
|
||||
innerRadius: number;
|
||||
ringSpacing: number;
|
||||
minNodeSpacing: number;
|
||||
nodePadding: number;
|
||||
overlapIterations: number;
|
||||
primaryLabelCount: number;
|
||||
};
|
||||
style: {
|
||||
nodeSizeScale: number;
|
||||
nodeBorderBoost: number;
|
||||
fillAlpha: number;
|
||||
shellAlpha: number;
|
||||
edgeSizeScale: number;
|
||||
edgeAlpha: number;
|
||||
glowAlpha: number;
|
||||
edgeVisibilityRatio: number;
|
||||
topIncidentEdges: number;
|
||||
};
|
||||
layout: {
|
||||
iterations: number;
|
||||
gravity: number;
|
||||
scalingRatio: number;
|
||||
edgeWeightInfluence: number;
|
||||
slowDown: number;
|
||||
settleMs: 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: "#5A7A9E",
|
||||
nodeMuted: "#121927",
|
||||
nodeBorder: "#7A92AE",
|
||||
nodeTintMix: 0.14,
|
||||
nodeCoreMix: 0.72,
|
||||
nodeShellAlpha: 0.97,
|
||||
nodeCoreAlpha: 1,
|
||||
edgeBackbone: "rgba(100, 148, 210, 0.38)",
|
||||
edgeStructure: "rgba(88, 140, 200, 0.28)",
|
||||
edgeInspection: "rgba(110, 165, 230, 0.48)",
|
||||
},
|
||||
accent: {
|
||||
selected: "#F2D288",
|
||||
hovered: "#8FE7FF",
|
||||
path: "#D79056",
|
||||
temporal: "#49D7FF",
|
||||
provenance: "#C9A5FF",
|
||||
inferred: "#D07B4D",
|
||||
},
|
||||
muted: {
|
||||
fallback: "rgba(96, 112, 136, 0.18)",
|
||||
nodeAlpha: 0.12,
|
||||
edgeOverview: "rgba(82, 100, 124, 0.12)",
|
||||
edgeStructure: "rgba(92, 112, 138, 0.18)",
|
||||
edgeInspection: "rgba(124, 148, 176, 0.26)",
|
||||
edgeFocus: "rgba(160, 186, 218, 0.42)",
|
||||
},
|
||||
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.72,
|
||||
labelThreshold: 0.995,
|
||||
labelBudget: 4,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.62,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 0.94,
|
||||
labelThreshold: 0.93,
|
||||
labelBudget: 18,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
inspection: {
|
||||
maxRatio: 0.5,
|
||||
nodeScale: 1,
|
||||
labelThreshold: 0.8,
|
||||
labelBudget: 40,
|
||||
edgePriorityThreshold: 0,
|
||||
arrowPriorityThreshold: 0.45,
|
||||
edgeSizeScale: 1.18,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
forceVisibleStates: ["hovered", "selected", "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.16,
|
||||
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.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
|
||||
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, 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: "inspection" },
|
||||
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "inspection" },
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
|
||||
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.96, minSize: 0.9, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 1.0, minSize: 1.0, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 1.1, minSize: 1.2, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.4, zIndex: 4, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
muted: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
},
|
||||
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,
|
||||
},
|
||||
grouped: {
|
||||
initialLayout: {
|
||||
innerRadius: 92,
|
||||
ringSpacing: 138,
|
||||
minNodeSpacing: 112,
|
||||
nodePadding: 28,
|
||||
overlapIterations: 18,
|
||||
primaryLabelCount: 6,
|
||||
},
|
||||
style: {
|
||||
nodeSizeScale: 0.9,
|
||||
nodeBorderBoost: 0.42,
|
||||
fillAlpha: 0.68,
|
||||
shellAlpha: 0.28,
|
||||
edgeSizeScale: 0.62,
|
||||
edgeAlpha: 0.32,
|
||||
glowAlpha: 0.14,
|
||||
edgeVisibilityRatio: 0.18,
|
||||
topIncidentEdges: 2,
|
||||
},
|
||||
layout: {
|
||||
iterations: 18,
|
||||
gravity: 0.06,
|
||||
scalingRatio: 18,
|
||||
edgeWeightInfluence: 0.08,
|
||||
slowDown: 34,
|
||||
settleMs: 1500,
|
||||
},
|
||||
},
|
||||
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: IS_DEV,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ZOOM_TIER_ORDER: GraphZoomTier[] = ["overview", "structure", "inspection"];
|
||||
|
||||
export function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function clamp(min: number, value: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function withAlpha(color: string | undefined, alpha: number): string {
|
||||
if (!color) {
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
|
||||
if (normalized.length === 6) {
|
||||
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (color.startsWith("rgba(")) {
|
||||
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
}
|
||||
|
||||
if (color.startsWith("rgb(")) {
|
||||
return color.replace("rgb(", "rgba(").replace(")", `, ${alpha})`);
|
||||
}
|
||||
|
||||
return `rgba(130, 145, 165, ${alpha})`;
|
||||
}
|
||||
|
||||
export function darkenHex(hexColor: string, amount: number): string {
|
||||
if (!hexColor.startsWith("#")) {
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
const hex = hexColor.slice(1);
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
|
||||
if (normalized.length !== 6) {
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
const clampChannel = (value: number) => clamp(0, value, 255);
|
||||
const red = clampChannel(Number.parseInt(normalized.slice(0, 2), 16) - amount);
|
||||
const green = clampChannel(Number.parseInt(normalized.slice(2, 4), 16) - amount);
|
||||
const blue = clampChannel(Number.parseInt(normalized.slice(4, 6), 16) - amount);
|
||||
|
||||
return `#${[red, green, blue].map((value) => value.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
export function blendHex(baseColor: string, tintColor: string, amount: number): string {
|
||||
if (!baseColor.startsWith("#") || !tintColor.startsWith("#")) {
|
||||
return tintColor || baseColor;
|
||||
}
|
||||
|
||||
const normalize = (value: string) => {
|
||||
const hex = value.slice(1);
|
||||
return hex.length === 3
|
||||
? hex.split("").map((char) => `${char}${char}`).join("")
|
||||
: hex;
|
||||
};
|
||||
|
||||
const base = normalize(baseColor);
|
||||
const tint = normalize(tintColor);
|
||||
if (base.length !== 6 || tint.length !== 6) {
|
||||
return tintColor || baseColor;
|
||||
}
|
||||
|
||||
const mix = clamp(0, amount, 1);
|
||||
const mixChannel = (left: number, right: number) => Math.round(left + (right - left) * mix);
|
||||
const channels = [0, 2, 4].map((offset) => {
|
||||
const left = Number.parseInt(base.slice(offset, offset + 2), 16);
|
||||
const right = Number.parseInt(tint.slice(offset, offset + 2), 16);
|
||||
return mixChannel(left, right).toString(16).padStart(2, "0");
|
||||
});
|
||||
|
||||
return `#${channels.join("")}`;
|
||||
}
|
||||
|
||||
export function getZoomTier(ratio: number): GraphZoomTier {
|
||||
if (ratio <= GRAPH_THEME.zoomTiers.inspection.maxRatio) {
|
||||
return "inspection";
|
||||
}
|
||||
if (ratio <= GRAPH_THEME.zoomTiers.structure.maxRatio) {
|
||||
return "structure";
|
||||
}
|
||||
return "overview";
|
||||
}
|
||||
|
||||
export function zoomTierAtLeast(current: GraphZoomTier, minimum: GraphZoomTier): boolean {
|
||||
return ZOOM_TIER_ORDER.indexOf(current) >= ZOOM_TIER_ORDER.indexOf(minimum);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type {
|
||||
GraphEffectAvailability,
|
||||
GraphEffectToggle,
|
||||
} from "../types";
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const EFFECTS_PANEL_ID = "effects-panel";
|
||||
|
||||
type EffectRowConfig = {
|
||||
key: GraphEffectToggle;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const EFFECT_ROWS: EffectRowConfig[] = [
|
||||
{
|
||||
key: "pathPulseEnabled",
|
||||
label: "Path Pulse",
|
||||
description: "Animated pulse on the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "pathFlowEnabled",
|
||||
label: "Path Flow",
|
||||
description: "Directional flow accents along the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "lensEnabled",
|
||||
label: "Neighborhood Lens",
|
||||
description: "Local emphasis around the hovered or selected node.",
|
||||
},
|
||||
{
|
||||
key: "legendEnabled",
|
||||
label: "Semantic Legend",
|
||||
description: "Compact semantic group legend for graph orientation.",
|
||||
},
|
||||
];
|
||||
|
||||
function renderAvailabilityText(availability: GraphEffectAvailability) {
|
||||
if (availability.available) {
|
||||
if (typeof availability.visibleSegments === "number" && typeof availability.segmentCap === "number") {
|
||||
return `${availability.reason} · ${availability.visibleSegments}/${availability.segmentCap} segments`;
|
||||
}
|
||||
return availability.reason;
|
||||
}
|
||||
|
||||
return availability.detail ? `${availability.reason} · ${availability.detail}` : availability.reason;
|
||||
}
|
||||
|
||||
function collectLegendItems(context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0]) {
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, context.theme.effects.legend.maxGroups);
|
||||
}
|
||||
|
||||
function EffectToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
availability,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
availability: GraphEffectAvailability;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{label}</div>
|
||||
<div style={rowDescriptionStyle}>{description}</div>
|
||||
<div style={rowMetaStyle}>{renderAvailabilityText(availability)}</div>
|
||||
</div>
|
||||
<button type="button" onClick={onToggle} style={checked ? toggleButtonActiveStyle : toggleButtonStyle}>
|
||||
{checked ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const explorationEffectsPlugin: GraphPlugin = {
|
||||
id: "exploration-effects",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "effects-toggle",
|
||||
label: "Effects",
|
||||
title: "Open exploration effects controls",
|
||||
active: context.isPanelOpen(EFFECTS_PANEL_ID),
|
||||
order: 18,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: EFFECTS_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(EFFECTS_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectsState = context.getEffectsState();
|
||||
const diagnosticsSnapshot = context.getDiagnosticsSnapshot();
|
||||
const availability = diagnosticsSnapshot?.effectAvailability;
|
||||
const legendItems = effectsState.legendEnabled ? collectLegendItems(context) : [];
|
||||
|
||||
return {
|
||||
id: EFFECTS_PANEL_ID,
|
||||
title: "Effects",
|
||||
placement: "bottom",
|
||||
order: 8,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 420,
|
||||
preferredHeight: 320,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Exploration effects</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Path and focus</div>
|
||||
{EFFECT_ROWS.map((row) => (
|
||||
<EffectToggleRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
checked={effectsState[row.key]}
|
||||
availability={
|
||||
availability?.[
|
||||
row.key === "pathPulseEnabled"
|
||||
? "pathPulse"
|
||||
: row.key === "pathFlowEnabled"
|
||||
? "pathFlow"
|
||||
: row.key === "lensEnabled"
|
||||
? "lens"
|
||||
: "legend"
|
||||
] ?? {
|
||||
enabled: effectsState[row.key],
|
||||
available: false,
|
||||
reason: "Waiting for graph runtime",
|
||||
}
|
||||
}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{effectsState.legendEnabled ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Semantic legend</div>
|
||||
{legendItems.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{legendItems.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
background: item.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${item.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{item.group}</div>
|
||||
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Legend data will populate when graph metadata is available.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Diagnostics</div>
|
||||
<EffectToggleRow
|
||||
label="Dev Diagnostics"
|
||||
description="Inspect plugin, interaction, and effect gating state."
|
||||
checked={effectsState.diagnosticsEnabled}
|
||||
availability={
|
||||
availability?.diagnostics ?? {
|
||||
enabled: effectsState.diagnosticsEnabled,
|
||||
available: false,
|
||||
reason: "Waiting for graph runtime",
|
||||
}
|
||||
}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: "diagnosticsEnabled" })}
|
||||
/>
|
||||
{effectsState.diagnosticsEnabled && diagnosticsSnapshot ? (
|
||||
<details style={detailsStyle}>
|
||||
<summary style={summaryStyle}>Runtime snapshot</summary>
|
||||
<pre style={diagnosticsPreStyle}>
|
||||
{JSON.stringify(diagnosticsSnapshot, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#dce9f8",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const toggleRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "8px 0",
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const rowDescriptionStyle: CSSProperties = {
|
||||
color: "#a1b7cf",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#7fc6ff",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const toggleButtonStyle: CSSProperties = {
|
||||
minWidth: 52,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
color: "#cfe0f4",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const toggleButtonActiveStyle: CSSProperties = {
|
||||
...toggleButtonStyle,
|
||||
background: "rgba(31, 111, 235, 0.24)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.28)",
|
||||
color: "#eef6ff",
|
||||
};
|
||||
|
||||
const legendRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const legendSwatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const detailsStyle: CSSProperties = {
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
background: "rgba(0,0,0,0.14)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
padding: "10px 12px",
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const diagnosticsPreStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
padding: "0 12px 12px",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
@@ -0,0 +1,527 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectAvailability,
|
||||
GraphEffectToggle,
|
||||
} from "../types";
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const EFFECTS_PANEL_ID = "effects-panel";
|
||||
|
||||
type EffectRowConfig = {
|
||||
key: GraphEffectToggle;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const SCENE_EFFECT_ROWS: EffectRowConfig[] = [
|
||||
{
|
||||
key: "pathPulseEnabled",
|
||||
label: "Path Pulse",
|
||||
description: "Animated pulse on the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "pathFlowEnabled",
|
||||
label: "Path Flow",
|
||||
description: "Directional flow accents along the active selected path.",
|
||||
},
|
||||
{
|
||||
key: "lensEnabled",
|
||||
label: "Neighborhood Lens",
|
||||
description: "Local emphasis around the hovered or selected node.",
|
||||
},
|
||||
{
|
||||
key: "temporalEmphasisEnabled",
|
||||
label: "Temporal Emphasis",
|
||||
description: "Subtle glow around temporally relevant nodes in the active time window.",
|
||||
},
|
||||
{
|
||||
key: "semanticRegionsEnabled",
|
||||
label: "Semantic Regions",
|
||||
description: "Quiet semantic hulls around the strongest visible topic clusters.",
|
||||
},
|
||||
{
|
||||
key: "contoursEnabled",
|
||||
label: "Contours",
|
||||
description: "Low-contrast density halos around the strongest visible anchors.",
|
||||
},
|
||||
{
|
||||
key: "legendEnabled",
|
||||
label: "Regions Summary",
|
||||
description: "Keep the regions and signals summary visible in the Effects panel.",
|
||||
},
|
||||
];
|
||||
|
||||
const INTELLIGENCE_EFFECT_ROWS: EffectRowConfig[] = [
|
||||
{
|
||||
key: "pathfindingEnabled",
|
||||
label: "Directed Pathfinding",
|
||||
description: "Compare the traced path against a strict local directed shortest path.",
|
||||
},
|
||||
{
|
||||
key: "communitiesEnabled",
|
||||
label: "Community Regions",
|
||||
description: "Detect stable Louvain communities for orientation and scene grouping.",
|
||||
},
|
||||
{
|
||||
key: "centralityEnabled",
|
||||
label: "Centrality Ranking",
|
||||
description: "Rank the strongest graph anchors for labels, regions, and navigation.",
|
||||
},
|
||||
];
|
||||
|
||||
const AVAILABILITY_KEYS: Record<GraphEffectToggle, keyof GraphDiagnosticsSnapshot["effectAvailability"]> = {
|
||||
pathPulseEnabled: "pathPulse",
|
||||
pathFlowEnabled: "pathFlow",
|
||||
lensEnabled: "lens",
|
||||
temporalEmphasisEnabled: "temporalEmphasis",
|
||||
semanticRegionsEnabled: "semanticRegions",
|
||||
contoursEnabled: "contours",
|
||||
pathfindingEnabled: "pathfinding",
|
||||
communitiesEnabled: "communities",
|
||||
centralityEnabled: "centrality",
|
||||
legendEnabled: "legend",
|
||||
diagnosticsEnabled: "diagnostics",
|
||||
};
|
||||
|
||||
function renderAvailabilityText(availability: GraphEffectAvailability) {
|
||||
if (availability.available) {
|
||||
if (typeof availability.visibleSegments === "number" && typeof availability.segmentCap === "number") {
|
||||
return `${availability.reason} - ${availability.visibleSegments}/${availability.segmentCap} segments`;
|
||||
}
|
||||
return availability.reason;
|
||||
}
|
||||
|
||||
return availability.detail ? `${availability.reason} - ${availability.detail}` : availability.reason;
|
||||
}
|
||||
|
||||
function collectFallbackLegendItems(context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0]) {
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, context.theme.effects.legend.maxGroups);
|
||||
}
|
||||
|
||||
function resolveAvailability(
|
||||
availabilityMap: GraphDiagnosticsSnapshot["effectAvailability"] | undefined,
|
||||
key: GraphEffectToggle,
|
||||
enabled: boolean,
|
||||
): GraphEffectAvailability {
|
||||
return availabilityMap?.[AVAILABILITY_KEYS[key]] ?? {
|
||||
enabled,
|
||||
available: false,
|
||||
reason: "Waiting for graph runtime",
|
||||
};
|
||||
}
|
||||
|
||||
function EffectToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
availability,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
availability: GraphEffectAvailability;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{label}</div>
|
||||
<div style={rowDescriptionStyle}>{description}</div>
|
||||
<div style={rowMetaStyle}>{renderAvailabilityText(availability)}</div>
|
||||
</div>
|
||||
<button type="button" onClick={onToggle} style={checked ? toggleButtonActiveStyle : toggleButtonStyle}>
|
||||
{checked ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRegionsAndSignals(
|
||||
context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0],
|
||||
analytics: GraphAnalyticsSnapshot | null,
|
||||
) {
|
||||
const fallbackLegendItems = collectFallbackLegendItems(context);
|
||||
const semanticRegions = analytics?.semanticRegions.summaries ?? [];
|
||||
const communities = analytics?.communities.summaries ?? [];
|
||||
const centrality = analytics?.centrality.topNodes ?? [];
|
||||
const directedPath = analytics?.directedPath ?? null;
|
||||
|
||||
if (!semanticRegions.length && !communities.length && !centrality.length && !fallbackLegendItems.length && !directedPath) {
|
||||
return <div style={emptyTextStyle}>Regions and intelligence summaries will populate when graph analytics are ready.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{semanticRegions.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Semantic regions</div>
|
||||
{semanticRegions.map((region) => (
|
||||
<div key={region.semanticGroup} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
background: region.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${region.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{region.semanticGroup}</div>
|
||||
<div style={rowMetaStyle}>
|
||||
{region.visibleNodeCount.toLocaleString()} visible / {region.nodeCount.toLocaleString()} total
|
||||
</div>
|
||||
</div>
|
||||
<div style={signalBadgeStyle}>{region.anchorLabel}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{communities.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Community anchors</div>
|
||||
{communities.slice(0, 3).map((community) => (
|
||||
<div key={community.communityId} style={signalRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{community.anchorLabel}</div>
|
||||
<div style={rowMetaStyle}>
|
||||
Community {community.communityId} - {community.visibleNodeCount} visible / {community.nodeCount} total
|
||||
</div>
|
||||
</div>
|
||||
<div style={signalBadgeStyle}>{community.dominantSemanticGroup}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{centrality.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Centrality leaders</div>
|
||||
{centrality.slice(0, 3).map((node) => (
|
||||
<div key={node.id} style={signalRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{node.label}</div>
|
||||
<div style={rowMetaStyle}>
|
||||
{node.semanticGroup} - score {node.score.toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={signalBadgeStyle}>deg {node.degree.toFixed(3)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{directedPath ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Directed pathfinding</div>
|
||||
<div style={signalRowStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{directedPath.ready ? "Local directed path ready" : "Waiting for path context"}</div>
|
||||
<div style={rowMetaStyle}>{directedPath.reason}</div>
|
||||
</div>
|
||||
{directedPath.ready ? (
|
||||
<div style={signalBadgeStyle}>
|
||||
{directedPath.length} hops{directedPath.verifiedAgainstActivePath ? " - match" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!semanticRegions.length && !communities.length && !centrality.length && fallbackLegendItems.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Fallback semantic legend</div>
|
||||
{fallbackLegendItems.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
background: item.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${item.color}40`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{item.group}</div>
|
||||
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const explorationEffectsPluginPhaseC: GraphPlugin = {
|
||||
id: "exploration-effects",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "effects-toggle",
|
||||
label: "Effects",
|
||||
title: "Open exploration effects controls",
|
||||
active: context.isPanelOpen(EFFECTS_PANEL_ID),
|
||||
order: 18,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: EFFECTS_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(EFFECTS_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectsState = context.getEffectsState();
|
||||
const diagnosticsSnapshot = context.getDiagnosticsSnapshot();
|
||||
const analyticsSnapshot = context.getAnalyticsSnapshot();
|
||||
const availability = diagnosticsSnapshot?.effectAvailability;
|
||||
const showSignalsSection =
|
||||
effectsState.legendEnabled
|
||||
|| effectsState.semanticRegionsEnabled
|
||||
|| effectsState.communitiesEnabled
|
||||
|| effectsState.centralityEnabled
|
||||
|| effectsState.pathfindingEnabled;
|
||||
|
||||
return {
|
||||
id: EFFECTS_PANEL_ID,
|
||||
title: "Effects",
|
||||
placement: "bottom",
|
||||
order: 8,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 460,
|
||||
preferredHeight: 360,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Exploration effects</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Scene effects</div>
|
||||
{SCENE_EFFECT_ROWS.map((row) => (
|
||||
<EffectToggleRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
checked={effectsState[row.key]}
|
||||
availability={resolveAvailability(availability, row.key, effectsState[row.key])}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Graph intelligence</div>
|
||||
{INTELLIGENCE_EFFECT_ROWS.map((row) => (
|
||||
<EffectToggleRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
checked={effectsState[row.key]}
|
||||
availability={resolveAvailability(availability, row.key, effectsState[row.key])}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showSignalsSection ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Regions and signals</div>
|
||||
{renderRegionsAndSignals(context, analyticsSnapshot)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Diagnostics</div>
|
||||
<EffectToggleRow
|
||||
label="Dev Diagnostics"
|
||||
description="Inspect plugin, interaction, and effect gating state."
|
||||
checked={effectsState.diagnosticsEnabled}
|
||||
availability={resolveAvailability(availability, "diagnosticsEnabled", effectsState.diagnosticsEnabled)}
|
||||
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: "diagnosticsEnabled" })}
|
||||
/>
|
||||
{effectsState.diagnosticsEnabled && diagnosticsSnapshot ? (
|
||||
<details style={detailsStyle}>
|
||||
<summary style={summaryStyle}>Runtime snapshot</summary>
|
||||
<pre style={diagnosticsPreStyle}>
|
||||
{JSON.stringify({ diagnostics: diagnosticsSnapshot, analytics: analyticsSnapshot }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#dce9f8",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const subsectionTitleStyle: CSSProperties = {
|
||||
color: "#9cc4ec",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const toggleRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "8px 0",
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const rowDescriptionStyle: CSSProperties = {
|
||||
color: "#a1b7cf",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#7fc6ff",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const toggleButtonStyle: CSSProperties = {
|
||||
minWidth: 52,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
color: "#cfe0f4",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const toggleButtonActiveStyle: CSSProperties = {
|
||||
...toggleButtonStyle,
|
||||
background: "rgba(31, 111, 235, 0.24)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.28)",
|
||||
color: "#eef6ff",
|
||||
};
|
||||
|
||||
const legendRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const legendSwatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const signalRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
};
|
||||
|
||||
const signalBadgeStyle: CSSProperties = {
|
||||
padding: "5px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(31, 111, 235, 0.16)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.16)",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const detailsStyle: CSSProperties = {
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
background: "rgba(0,0,0,0.14)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const summaryStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
padding: "10px 12px",
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const diagnosticsPreStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
padding: "0 12px 12px",
|
||||
color: "#dce9f8",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export { explorationEffectsPluginPhaseC as explorationEffectsPlugin } from "./explorationEffectsPluginPhaseC";
|
||||
export { legendPlugin } from "./legendPlugin";
|
||||
export { neighborhoodPanelPlugin } from "./neighborhoodPanelPlugin";
|
||||
export { temporalOverlayPlugin } from "./temporalOverlayPlugin";
|
||||
export type {
|
||||
GraphPlugin,
|
||||
GraphPluginActionRequest,
|
||||
GraphPluginContext,
|
||||
GraphInspectorState,
|
||||
GraphPluginId,
|
||||
GraphPluginOverlayDescriptor,
|
||||
GraphPluginPanelDescriptor,
|
||||
GraphPluginRegistryEntry,
|
||||
GraphPluginToolbarItem,
|
||||
GraphTemporalState,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const LEGEND_PANEL_ID = "legend-panel";
|
||||
const MAX_GROUPS = 8;
|
||||
|
||||
export const legendPlugin: GraphPlugin = {
|
||||
id: "legend",
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
onStateChange: () => {},
|
||||
toolbarItems: (context) => [
|
||||
{
|
||||
id: "legend-toggle",
|
||||
label: "Legend",
|
||||
title: "Toggle semantic legend",
|
||||
active: context.isPanelOpen(LEGEND_PANEL_ID),
|
||||
order: 20,
|
||||
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: LEGEND_PANEL_ID }),
|
||||
},
|
||||
],
|
||||
renderPanel: (context) => {
|
||||
if (!context.isPanelOpen(LEGEND_PANEL_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
const items = [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, MAX_GROUPS);
|
||||
|
||||
return {
|
||||
id: LEGEND_PANEL_ID,
|
||||
title: "Legend",
|
||||
placement: "bottom",
|
||||
order: 10,
|
||||
defaultOpen: false,
|
||||
preferredWidth: 320,
|
||||
preferredHeight: 220,
|
||||
content: (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={panelEyebrowStyle}>Semantic groups</div>
|
||||
{items.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...swatchStyle,
|
||||
background: item.color,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 18px ${item.color}44`,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitleStyle}>{item.group}</div>
|
||||
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Legend will populate when the graph metadata is available.</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const panelBodyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelEyebrowStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const legendRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
};
|
||||
|
||||
const swatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const rowTitleStyle: CSSProperties = {
|
||||
color: "#f3f7fd",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const rowMetaStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8ea4be",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
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();
|
||||
const displayState = context.getDisplayState();
|
||||
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);
|
||||
const hiddenNeighborCount = displayState.selectedCollapsedNeighborIds.length;
|
||||
const aggregatedEdgeCount = context.displayGraph
|
||||
.edges()
|
||||
.map((edgeId) => context.displayGraph.getEdgeAttributes(edgeId) as { isAggregated?: boolean })
|
||||
.filter((attrs) => attrs.isAggregated).length;
|
||||
|
||||
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>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.dispatchAction({ type: "collapseNeighborhood" })}
|
||||
disabled={!selected.canCollapseNeighborhood || selected.isNeighborhoodCollapsed}
|
||||
style={controlButtonStyle}
|
||||
>
|
||||
Collapse Neighborhood
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.dispatchAction({ type: "expandNeighborhood" })}
|
||||
disabled={!selected.isNeighborhoodCollapsed}
|
||||
style={controlButtonStyle}
|
||||
>
|
||||
Expand Neighborhood
|
||||
</button>
|
||||
</div>
|
||||
{hiddenNeighborCount > 0 ? (
|
||||
<div style={summaryStyle}>
|
||||
{hiddenNeighborCount.toLocaleString()} lower-priority neighbors are collapsed in the current view.
|
||||
</div>
|
||||
) : null}
|
||||
{aggregatedEdgeCount > 0 ? (
|
||||
<div style={summaryStyle}>
|
||||
{aggregatedEdgeCount.toLocaleString()} aggregated structural bundle{aggregatedEdgeCount === 1 ? "" : "s"} visible.
|
||||
</div>
|
||||
) : null}
|
||||
{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 controlButtonStyle: CSSProperties = {
|
||||
padding: "7px 10px",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
borderRadius: 10,
|
||||
color: "#dce7f4",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
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,105 @@
|
||||
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,
|
||||
GraphDisplayStateSnapshot,
|
||||
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: "collapseNeighborhood" }
|
||||
| { type: "expandNeighborhood" }
|
||||
| { 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;
|
||||
getDisplayState: () => GraphDisplayStateSnapshot;
|
||||
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,77 @@
|
||||
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,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
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;
|
||||
graphVersion: number;
|
||||
layoutMode?: GraphDisplayMeta["layoutMode"];
|
||||
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 {
|
||||
graphVersion: number;
|
||||
graphReady: boolean;
|
||||
displayGraph: GraphSceneGraph;
|
||||
displayMeta: GraphDisplayMeta;
|
||||
displayState?: GraphDisplayStateSnapshot;
|
||||
selectedNodeId: string;
|
||||
focusedNodeId: 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;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => 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,301 @@
|
||||
export type GraphViewMode = "focused" | "full" | "grouped";
|
||||
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 type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
|
||||
|
||||
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 interface GraphDisplayStateSnapshot {
|
||||
aggregationEnabled: boolean;
|
||||
groupedViewAvailable: boolean;
|
||||
groupedViewReason: string | null;
|
||||
selectedRootNodeId: string | null;
|
||||
selectedVisibleNeighborIds: string[];
|
||||
selectedCollapsedNeighborIds: string[];
|
||||
selectedNodeKind: GraphSelectedNodeKind;
|
||||
canActivateFocused: boolean;
|
||||
resolvedFocusedNodeId: string | null;
|
||||
focusedUnavailableReason: string | null;
|
||||
}
|
||||
|
||||
export type GraphDisplayLayoutMode = "base" | "mirrored" | "owned";
|
||||
|
||||
export interface GraphDisplayMeta {
|
||||
layoutMode: GraphDisplayLayoutMode;
|
||||
positionSource: "store" | "display";
|
||||
tracksStoreNodePositions: boolean;
|
||||
hasSyntheticNodes: 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;
|
||||
visibleNeighborCount: number;
|
||||
collapsedNeighborCount: number;
|
||||
isNeighborhoodCollapsed: boolean;
|
||||
canCollapseNeighborhood: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
isAggregated: boolean;
|
||||
aggregateCount: number;
|
||||
rawEdgeIds: string[];
|
||||
bundleKind: "parallel" | "bidirectional" | "community" | null;
|
||||
dominantEdgeType: string | null;
|
||||
representativeWeight: 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,270 @@
|
||||
/**
|
||||
* 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";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
|
||||
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!`);
|
||||
logEvent("import", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges from ${file.name}`, {
|
||||
file: file.name,
|
||||
nodesImported: data.nodes_imported,
|
||||
edgesImported: data.edges_imported,
|
||||
});
|
||||
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.");
|
||||
logEvent("export", `Exported graph as ${exportFormat.toUpperCase()}`, { format: exportFormat });
|
||||
} 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,339 @@
|
||||
/**
|
||||
* src/workspaces/ManageWorkspace/KGOverviewTab.tsx
|
||||
*
|
||||
* Quick-view dashboard for the Knowledge Graph: node/edge counts,
|
||||
* type distributions, and top connected nodes.
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Network, RefreshCw, Loader2 } from "lucide-react";
|
||||
|
||||
interface KGStats {
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
node_types?: Record<string, number>;
|
||||
edge_types?: Record<string, number>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface NodeItem {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface NodeListResponse {
|
||||
nodes: NodeItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
function TypeBar({ label, count, total, color }: { label: string; count: number; total: number; color: string }) {
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "5px 0" }}>
|
||||
<div style={{ width: 120, flexShrink: 0, color: "#c6d4e3", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={label}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 6, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
transition: "width 400ms ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 52, textAlign: "right", flexShrink: 0, display: "flex", gap: 6, justifyContent: "flex-end" }}>
|
||||
<span style={{ color: "#8b949e", fontSize: 11 }}>{count.toLocaleString()}</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NODE_COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff", "#f2b66d"];
|
||||
const EDGE_COLORS = ["#4cc38a", "#79c0ff", "#d2a8ff", "#f2b66d", "#ff7b72", "#58a6ff", "#4aa3ff", "#8A56D8"];
|
||||
|
||||
function buildTypeMap(nodes: NodeItem[], key: keyof NodeItem): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
for (const node of nodes) {
|
||||
const val = String(node[key] ?? "unknown");
|
||||
map[val] = (map[val] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function KGOverviewTab() {
|
||||
const [stats, setStats] = useState<KGStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [topNodes, setTopNodes] = useState<{ node: NodeItem; neighborCount: number }[]>([]);
|
||||
const [nodeTypeMap, setNodeTypeMap] = useState<Record<string, number>>({});
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [statsRes, nodesRes] = await Promise.all([
|
||||
fetch("/api/graph/stats"),
|
||||
fetch("/api/graph/nodes?limit=500"),
|
||||
]);
|
||||
|
||||
if (statsRes.ok) {
|
||||
const statsData: KGStats = await statsRes.json();
|
||||
setStats(statsData);
|
||||
}
|
||||
|
||||
if (nodesRes.ok) {
|
||||
const nodesData: NodeListResponse = await nodesRes.json();
|
||||
const nodes = nodesData.nodes ?? [];
|
||||
setNodeTypeMap(buildTypeMap(nodes, "type"));
|
||||
|
||||
// Simulate neighbor counts via edges fetch for top-N
|
||||
const edgesRes = await fetch("/api/graph/edges?limit=2000");
|
||||
if (edgesRes.ok) {
|
||||
const edgesData = await edgesRes.json();
|
||||
const edges: { source: string; target: string }[] = edgesData.edges ?? [];
|
||||
const degreeMap: Record<string, number> = {};
|
||||
for (const edge of edges) {
|
||||
degreeMap[edge.source] = (degreeMap[edge.source] ?? 0) + 1;
|
||||
degreeMap[edge.target] = (degreeMap[edge.target] ?? 0) + 1;
|
||||
}
|
||||
const sorted = nodes
|
||||
.map((n) => ({ node: n, neighborCount: degreeMap[n.id] ?? 0 }))
|
||||
.sort((a, b) => b.neighborCount - a.neighborCount)
|
||||
.slice(0, 10);
|
||||
setTopNodes(sorted);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load graph overview. Ensure the server is running.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOverview();
|
||||
}, [fetchOverview]);
|
||||
|
||||
const nodeTypeEntries = Object.entries(nodeTypeMap).sort((a, b) => b[1] - a[1]);
|
||||
const edgeTypeEntries = stats?.edge_types
|
||||
? Object.entries(stats.edge_types).sort((a, b) => b[1] - a[1])
|
||||
: [];
|
||||
|
||||
const totalNodes = stats?.node_count ?? 0;
|
||||
const totalEdges = stats?.edge_count ?? 0;
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Network size={18} color="#4aa3ff" />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>KG Overview</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>Quick view of the Knowledge Graph structure and health</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => void fetchOverview()} disabled={loading} style={refreshBtnStyle}>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div style={{ margin: "16px 24px", padding: "10px 14px", borderRadius: 10, background: "rgba(255,123,114,0.08)", border: "1px solid rgba(255,123,114,0.2)", color: "#ff7b72", fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={scrollBodyStyle}>
|
||||
{/* Stats chips */}
|
||||
<div style={statsRowStyle}>
|
||||
{[
|
||||
{ label: "Nodes", value: totalNodes.toLocaleString(), color: "#4aa3ff", sub: `${nodeTypeEntries.length} types` },
|
||||
{ label: "Edges", value: totalEdges.toLocaleString(), color: "#4cc38a", sub: `${edgeTypeEntries.length} relationship types` },
|
||||
{ label: "Density", value: totalNodes > 1 ? ((totalEdges / (totalNodes * (totalNodes - 1))) * 100).toFixed(3) + "%" : "—", color: "#d2a8ff", sub: "graph density" },
|
||||
].map(({ label, value, color, sub }) => (
|
||||
<div key={label} style={statCardStyle}>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ color, fontSize: 28, fontWeight: 800, letterSpacing: "-0.04em", lineHeight: 1 }}>{loading ? "—" : value}</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, marginTop: 4 }}>{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Type breakdowns */}
|
||||
<div style={sectionRowStyle}>
|
||||
{/* Node types */}
|
||||
<div style={breakdownCardStyle}>
|
||||
<div style={sectionTitleStyle}>Node Type Breakdown</div>
|
||||
{loading ? (
|
||||
<div style={skeletonWrapStyle}>
|
||||
{[80, 65, 45, 35, 25].map((w, i) => (
|
||||
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
|
||||
))}
|
||||
</div>
|
||||
) : nodeTypeEntries.length === 0 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 12 }}>No data — load the graph first.</div>
|
||||
) : (
|
||||
nodeTypeEntries.slice(0, 8).map(([type, count], i) => (
|
||||
<TypeBar key={type} label={type} count={count} total={totalNodes || 1} color={NODE_COLORS[i % NODE_COLORS.length]} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edge types */}
|
||||
<div style={breakdownCardStyle}>
|
||||
<div style={sectionTitleStyle}>Edge Type Breakdown</div>
|
||||
{loading ? (
|
||||
<div style={skeletonWrapStyle}>
|
||||
{[70, 55, 48, 30, 20].map((w, i) => (
|
||||
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
|
||||
))}
|
||||
</div>
|
||||
) : edgeTypeEntries.length === 0 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 12 }}>Edge type breakdown requires the stats endpoint to return edge_types.</div>
|
||||
) : (
|
||||
edgeTypeEntries.slice(0, 8).map(([type, count], i) => (
|
||||
<TypeBar key={type} label={type} count={count} total={totalEdges || 1} color={EDGE_COLORS[i % EDGE_COLORS.length]} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top connected nodes */}
|
||||
{topNodes.length > 0 ? (
|
||||
<div style={breakdownCardStyle}>
|
||||
<div style={sectionTitleStyle}>Top Connected Nodes (by degree)</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 8, marginTop: 2 }}>
|
||||
{topNodes.map(({ node, neighborCount }, rank) => (
|
||||
<div key={node.id} style={topNodeRowStyle}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, fontWeight: 700, minWidth: 20 }}>#{rank + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{node.content || node.id}
|
||||
</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 11 }}>{node.type}</div>
|
||||
</div>
|
||||
<div style={{ color: "#4aa3ff", fontSize: 12, fontWeight: 700, flexShrink: 0 }}>
|
||||
{neighborCount} conn.
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0d1117",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "20px 24px 16px",
|
||||
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const refreshBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.16)",
|
||||
background: "rgba(74,163,255,0.08)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const scrollBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "20px 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
};
|
||||
|
||||
const statsRowStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const statCardStyle: React.CSSProperties = {
|
||||
padding: "18px 20px",
|
||||
borderRadius: 16,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.8), rgba(22,27,34,0.5))",
|
||||
border: "1px solid rgba(127,208,255,0.1)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)",
|
||||
};
|
||||
|
||||
const sectionRowStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const breakdownCardStyle: React.CSSProperties = {
|
||||
padding: "16px 18px",
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.7), rgba(22,27,34,0.4))",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const topNodeRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 10,
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const skeletonWrapStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const skeletonBarStyle: React.CSSProperties = {
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
animation: "skeleton-pulse 1.4s ease-in-out infinite",
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* src/workspaces/ManageWorkspace/OntologySummaryTab.tsx
|
||||
*
|
||||
* A compact read-only view of all loaded SKOS ConceptSchemes and their
|
||||
* top-level concepts. Clicking a concept deep-links to the Vocabulary Browser.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { BookOpen, ChevronRight, ChevronDown, ExternalLink } from "lucide-react";
|
||||
import { useVocabularies, useConceptHierarchy } from "../VocabularyWorkspace/queries";
|
||||
import type { ConceptNode, VocabularyScheme } from "../VocabularyWorkspace/types";
|
||||
|
||||
function countConcepts(nodes: ConceptNode[]): number {
|
||||
return nodes.reduce((acc, node) => {
|
||||
return acc + 1 + countConcepts(node.children ?? []);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function ConceptRow({
|
||||
concept,
|
||||
depth,
|
||||
onSelect,
|
||||
}: {
|
||||
concept: ConceptNode;
|
||||
depth: number;
|
||||
onSelect: (concept: ConceptNode) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const children = concept.children ?? [];
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingLeft: 12 + depth * 16,
|
||||
paddingRight: 12,
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
color: depth === 0 ? "#c6d4e3" : "#8b949e",
|
||||
fontSize: depth === 0 ? 13 : 12,
|
||||
transition: "background 120ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "rgba(74,163,255,0.07)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "transparent"; }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", padding: 0, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ width: 12, display: "inline-block" }} />
|
||||
)}
|
||||
<span
|
||||
onClick={() => onSelect(concept)}
|
||||
style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{concept.pref_label || concept.uri}
|
||||
</span>
|
||||
{children.length > 0 ? (
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>{children.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{expanded && hasChildren
|
||||
? children.map((child) => (
|
||||
<ConceptRow key={child.uri} concept={child} depth={depth + 1} onSelect={onSelect} />
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemePanel({
|
||||
scheme,
|
||||
onSelectConcept,
|
||||
}: {
|
||||
scheme: VocabularyScheme;
|
||||
onSelectConcept: (concept: ConceptNode) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const { data: hierarchy = [], isLoading } = useConceptHierarchy(scheme.uri);
|
||||
const totalConcepts = countConcepts(hierarchy);
|
||||
|
||||
return (
|
||||
<div style={schemeCardStyle}>
|
||||
{/* Scheme header */}
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
style={schemeHeaderStyle}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{expanded ? <ChevronDown size={14} color="#8b949e" /> : <ChevronRight size={14} color="#8b949e" />}
|
||||
<span style={{ color: "#e6edf3", fontSize: 14, fontWeight: 700 }}>{scheme.label}</span>
|
||||
</div>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>
|
||||
{isLoading ? "…" : `${totalConcepts} concept${totalConcepts !== 1 ? "s" : ""}`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Concept tree */}
|
||||
{expanded ? (
|
||||
<div style={{ paddingTop: 4, paddingBottom: 8 }}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12 }}>Loading concepts…</div>
|
||||
) : hierarchy.length === 0 ? (
|
||||
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12, fontStyle: "italic" }}>
|
||||
No concepts found in this scheme.
|
||||
</div>
|
||||
) : (
|
||||
hierarchy.map((concept) => (
|
||||
<ConceptRow key={concept.uri} concept={concept} depth={0} onSelect={onSelectConcept} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OntologySummaryTab({
|
||||
onOpenVocabularyBrowser,
|
||||
}: {
|
||||
onOpenVocabularyBrowser?: () => void;
|
||||
}) {
|
||||
const { data: schemes = [], isLoading } = useVocabularies();
|
||||
const [selectedConcept, setSelectedConcept] = useState<ConceptNode | null>(null);
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<BookOpen size={18} color="#d2a8ff" />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Ontology Summary</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>
|
||||
{isLoading
|
||||
? "Loading schemes…"
|
||||
: `${schemes.length} vocabulary scheme${schemes.length !== 1 ? "s" : ""} loaded`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onOpenVocabularyBrowser ? (
|
||||
<button onClick={onOpenVocabularyBrowser} style={openBrowserBtnStyle}>
|
||||
<ExternalLink size={12} />
|
||||
<span>Open Full Browser</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
|
||||
{/* Scheme tree column */}
|
||||
<div style={treeColumnStyle}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{[90, 75, 60].map((w, i) => (
|
||||
<div key={i} style={{ height: 36, borderRadius: 8, background: "rgba(255,255,255,0.04)", width: `${w}%` }} />
|
||||
))}
|
||||
</div>
|
||||
) : schemes.length === 0 ? (
|
||||
<div style={emptyStateStyle}>
|
||||
<BookOpen size={32} color="rgba(210,168,255,0.15)" />
|
||||
<div style={{ color: "#8b949e", fontSize: 13, marginTop: 12 }}>No vocabulary schemes loaded</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 240 }}>
|
||||
Import a .ttl or .rdf file via the Vocabulary Browser to see your ontology here.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "12px 8px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{schemes.map((scheme) => (
|
||||
<SchemePanel key={scheme.uri} scheme={scheme} onSelectConcept={setSelectedConcept} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Concept detail panel */}
|
||||
{selectedConcept ? (
|
||||
<div style={detailPanelStyle}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 16 }}>
|
||||
<div style={{ color: "#d2a8ff", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
Concept Detail
|
||||
</div>
|
||||
<button onClick={() => setSelectedConcept(null)} style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", fontSize: 16 }}>×</button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ color: "#ffffff", fontSize: 18, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 6px 0" }}>
|
||||
{selectedConcept.pref_label}
|
||||
</h3>
|
||||
{selectedConcept.notation ? (
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginBottom: 8 }}>Notation: {selectedConcept.notation}</div>
|
||||
) : null}
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", wordBreak: "break-all", marginBottom: 14 }}>
|
||||
{selectedConcept.uri}
|
||||
</div>
|
||||
|
||||
{selectedConcept.description ? (
|
||||
<div style={detailSectionStyle}>
|
||||
<div style={detailLabelStyle}>Description</div>
|
||||
<div style={{ color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>{selectedConcept.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedConcept.alt_labels?.length ? (
|
||||
<div style={detailSectionStyle}>
|
||||
<div style={detailLabelStyle}>Alternative Labels</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{selectedConcept.alt_labels.map((label) => (
|
||||
<span key={label} style={altLabelChipStyle}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(selectedConcept.children?.length ?? 0) > 0 ? (
|
||||
<div style={detailSectionStyle}>
|
||||
<div style={detailLabelStyle}>Narrower Concepts ({selectedConcept.children!.length})</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{selectedConcept.children!.slice(0, 8).map((child) => (
|
||||
<div
|
||||
key={child.uri}
|
||||
onClick={() => setSelectedConcept(child)}
|
||||
style={{ color: "#79c0ff", fontSize: 12, cursor: "pointer", padding: "3px 0" }}
|
||||
>
|
||||
→ {child.pref_label}
|
||||
</div>
|
||||
))}
|
||||
{selectedConcept.children!.length > 8 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 11 }}>+{selectedConcept.children!.length - 8} more</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0d1117",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "20px 24px 16px",
|
||||
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const openBrowserBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(210,168,255,0.22)",
|
||||
background: "rgba(210,168,255,0.08)",
|
||||
color: "#d2a8ff",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const treeColumnStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
borderRight: "1px solid rgba(255,255,255,0.06)",
|
||||
};
|
||||
|
||||
const schemeCardStyle: React.CSSProperties = {
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(210,168,255,0.1)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const schemeHeaderStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 14px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const detailPanelStyle: React.CSSProperties = {
|
||||
width: 300,
|
||||
padding: "20px",
|
||||
overflowY: "auto",
|
||||
borderLeft: "1px solid rgba(255,255,255,0.06)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const detailSectionStyle: React.CSSProperties = {
|
||||
marginTop: 14,
|
||||
paddingTop: 12,
|
||||
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||
};
|
||||
|
||||
const detailLabelStyle: React.CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const altLabelChipStyle: React.CSSProperties = {
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 40,
|
||||
height: "100%",
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const SAMPLE_FACTS = `inhibits(Metformin, mTOR)\ncauses(mTOR, Neurodegeneration)`;
|
||||
const SAMPLE_RULE = `IF inhibits(Metformin, mTOR) AND causes(mTOR, Neurodegeneration) THEN candidate(Metformin, Alzheimer's)`;
|
||||
|
||||
export function ReasoningWorkspace() {
|
||||
const queryClient = useQueryClient();
|
||||
const [facts, setFacts] = useState(SAMPLE_FACTS);
|
||||
const [rules, setRules] = useState(SAMPLE_RULE);
|
||||
const [applyToGraph, setApplyToGraph] = useState(true);
|
||||
const [result, setResult] = useState<{ inferred_facts?: string[]; rules_fired?: number; added_edges?: number; mutated?: boolean } | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleRun() {
|
||||
setIsRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await fetch("/api/reason", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
facts: facts.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
rules: rules.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
mode: "forward",
|
||||
apply_to_graph: applyToGraph,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `Reasoning failed with status ${response.status}`);
|
||||
}
|
||||
setResult(data);
|
||||
if (data.mutated) {
|
||||
queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
|
||||
}
|
||||
} catch (runError) {
|
||||
setError(runError instanceof Error ? runError.message : "Reasoning failed");
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.2fr 1fr", gap: 24, height: "100%", padding: 24, boxSizing: "border-box", background: "#0d1117" }}>
|
||||
<div style={panelStyle}>
|
||||
<h3 style={titleStyle}>Facts</h3>
|
||||
<p style={copyStyle}>Enter one fact per line using `predicate(subject, object)` form.</p>
|
||||
<textarea value={facts} onChange={(event) => setFacts(event.target.value)} style={textareaStyle} />
|
||||
|
||||
<h3 style={{ ...titleStyle, marginTop: 18 }}>Rules</h3>
|
||||
<p style={copyStyle}>Write rules in `IF ... AND ... THEN ...` format. If the advanced reasoner is unavailable, the explorer falls back to an internal rule matcher for this format.</p>
|
||||
<textarea value={rules} onChange={(event) => setRules(event.target.value)} style={{ ...textareaStyle, minHeight: 160 }} />
|
||||
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, color: "#c9d1d9", fontSize: 13, marginTop: 16 }}>
|
||||
<input type="checkbox" checked={applyToGraph} onChange={(event) => setApplyToGraph(event.target.checked)} />
|
||||
Write inferred binary facts back into the graph as inferred edges
|
||||
</label>
|
||||
|
||||
<button onClick={handleRun} disabled={isRunning} style={runButtonStyle}>
|
||||
{isRunning ? "Running..." : "Run Reasoning"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={panelStyle}>
|
||||
<h3 style={titleStyle}>Inference Results</h3>
|
||||
|
||||
{error ? <div style={{ color: "#ff7b72", marginBottom: 12 }}>{error}</div> : null}
|
||||
|
||||
{result ? (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 14 }}>
|
||||
<span style={pillStyle}>rules fired: {result.rules_fired ?? 0}</span>
|
||||
<span style={pillStyle}>edges added: {result.added_edges ?? 0}</span>
|
||||
<span style={pillStyle}>{result.mutated ? "graph updated" : "preview only"}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{(result.inferred_facts || []).length ? (
|
||||
result.inferred_facts?.map((fact) => (
|
||||
<div key={fact} style={factCardStyle}>{fact}</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", fontSize: 13 }}>No inferred facts were produced.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", fontSize: 13 }}>Run a rule set to inspect inferred statements here.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(13, 17, 23, 0.78), rgba(22, 27, 34, 0.64))",
|
||||
border: "1px solid rgba(88, 166, 255, 0.18)",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
color: "#fff",
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const copyStyle: React.CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: "8px 0 14px",
|
||||
};
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
minHeight: 120,
|
||||
resize: "vertical",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(88, 166, 255, 0.18)",
|
||||
background: "rgba(0, 0, 0, 0.25)",
|
||||
color: "#e6edf3",
|
||||
padding: 12,
|
||||
fontFamily: "Consolas, monospace",
|
||||
fontSize: 13,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const runButtonStyle: React.CSSProperties = {
|
||||
marginTop: 18,
|
||||
border: "1px solid rgba(88, 166, 255, 0.3)",
|
||||
background: "rgba(31, 111, 235, 0.2)",
|
||||
color: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "11px 14px",
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const pillStyle: React.CSSProperties = {
|
||||
color: "#79c0ff",
|
||||
border: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
borderRadius: 999,
|
||||
padding: "5px 10px",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const factCardStyle: React.CSSProperties = {
|
||||
color: "#e6edf3",
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
borderRadius: 10,
|
||||
padding: "10px 12px",
|
||||
fontFamily: "Consolas, monospace",
|
||||
fontSize: 13,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx
|
||||
*/
|
||||
import { useState, useRef } from "react";
|
||||
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
export function SparqlWorkspace() {
|
||||
const monaco = useMonaco();
|
||||
const editorRef = useRef<any>(null);
|
||||
const [query, setQuery] = useState("SELECT ?s ?p ?o\nWHERE {\n ?s ?p ?o\n}\nLIMIT 10");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
function handleEditorWillMount(monacoIns: any) {
|
||||
if (!monacoIns.languages.getLanguages().some((l: any) => l.id === "sparql")) {
|
||||
monacoIns.languages.register({ id: "sparql" });
|
||||
|
||||
monacoIns.languages.setMonarchTokensProvider("sparql", {
|
||||
keywords: ["SELECT", "WHERE", "LIMIT", "FILTER", "OPTIONAL", "PREFIX", "ORDER BY", "DESC", "ASC"],
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/[a-zA-Z_]\w*/, { cases: { "@keywords": "keyword", "@default": "identifier" } }],
|
||||
[/[?\$][a-zA-Z_]\w*/, "variable.name"],
|
||||
[/<[^>]+>/, "string.uri"],
|
||||
[/".*?"/, "string"],
|
||||
[/#.*/, "comment"],
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
monacoIns.editor.defineTheme("sparql-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "58a6ff", fontStyle: "bold" },
|
||||
{ token: "variable.name", foreground: "79c0ff" },
|
||||
{ token: "string.uri", foreground: "a5d6ff" },
|
||||
{ token: "string", foreground: "a5d6ff" },
|
||||
{ token: "comment", foreground: "8b949e" }
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#0d1117",
|
||||
"editor.lineHighlightBackground": "#161b22",
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorDidMount(editor: any) {
|
||||
editorRef.current = editor;
|
||||
}
|
||||
|
||||
async function handleRun() {
|
||||
setIsLoading(true);
|
||||
setResult(null);
|
||||
monaco?.editor.setModelMarkers(editorRef.current.getModel(), "sparql", []);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/sparql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
if (data.error_line && monaco && editorRef.current) {
|
||||
monaco.editor.setModelMarkers(editorRef.current.getModel(), "sparql", [
|
||||
{
|
||||
startLineNumber: data.error_line,
|
||||
startColumn: data.error_column || 1,
|
||||
endLineNumber: data.error_line,
|
||||
endColumn: 100,
|
||||
message: data.error,
|
||||
severity: monaco.MarkerSeverity.Error
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 24, boxSizing: "border-box", gap: 24 }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h2 style={{ color: "#ffffff", margin: 0, fontSize: 24 }}>SPARQL Query Engine</h2>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isLoading}
|
||||
style={{ background: "#238636", color: "#fff", border: "none", padding: "8px 24px", borderRadius: 6, fontWeight: 600, cursor: "pointer" }}
|
||||
>
|
||||
{isLoading ? "Running..." : "Run Query"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, overflow: "hidden", border: "1px solid rgba(88,166,255,0.2)" }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sparql"
|
||||
theme="sparql-dark"
|
||||
value={query}
|
||||
onChange={(v) => setQuery(v || "")}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{ minimap: { enabled: false }, fontSize: 14, fontFamily: "monospace" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel" style={{ height: "30%", borderRadius: 12, padding: 16, overflowY: "auto" }}>
|
||||
<h3 style={{ color: "#ffffff", margin: "0 0 16px 0", fontSize: 16 }}>Results</h3>
|
||||
{result?.error ? (
|
||||
<div style={{ color: "#ff7b72" }}>{result.error}</div>
|
||||
) : result?.rows ? (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", color: "#c9d1d9" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
{result.columns.map((c: string) => <th key={c} style={{ textAlign: "left", padding: 8 }}>{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((r: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
{result.columns.map((c: string) => <td key={c} style={{ padding: 8 }}>{r[c]}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e" }}>No results to display. Run a query first.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* ConceptTree.tsx — react-arborist SKOS hierarchy viewer.
|
||||
*
|
||||
* Styled for the Palantir/Glassmorphism dark theme rather than
|
||||
* the default light-mode colours.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Tree } from 'react-arborist';
|
||||
import type { NodeRendererProps } from 'react-arborist';
|
||||
import { ChevronRight, ChevronDown, Folder, FileText } from 'lucide-react';
|
||||
import type { ConceptNode } from './types';
|
||||
|
||||
interface ConceptTreeProps {
|
||||
data: ConceptNode[];
|
||||
onSelectConcept: (concept: ConceptNode) => void;
|
||||
}
|
||||
|
||||
export const ConceptTree: React.FC<ConceptTreeProps> = ({ data, onSelectConcept }) => {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Tree
|
||||
data={data}
|
||||
idAccessor="uri"
|
||||
width="100%"
|
||||
height={600}
|
||||
indent={24}
|
||||
rowHeight={36}
|
||||
childrenAccessor="children"
|
||||
>
|
||||
{(nodeProps: NodeRendererProps<ConceptNode>) => {
|
||||
const { node, style, dragHandle } = nodeProps;
|
||||
const isFolder = node.children && node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
onClick={() => {
|
||||
node.toggle();
|
||||
onSelectConcept(node.data);
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: node.isSelected
|
||||
? 'rgba(88,166,255,0.12)'
|
||||
: 'transparent',
|
||||
userSelect: 'none',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!node.isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'rgba(88,166,255,0.06)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!node.isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 20, display: 'flex', justifyContent: 'center' }}>
|
||||
{isFolder ? (
|
||||
node.isOpen
|
||||
? <ChevronDown size={14} color="#8b949e" />
|
||||
: <ChevronRight size={14} color="#8b949e" />
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<span style={{ marginRight: 8, display: 'flex', alignItems: 'center' }}>
|
||||
{isFolder
|
||||
? <Folder size={14} color="#d2a8ff" />
|
||||
: <FileText size={14} color="#484f58" />
|
||||
}
|
||||
</span>
|
||||
|
||||
<span style={{
|
||||
fontSize: 13, color: '#c9d1d9',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{node.data.pref_label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Tree>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* ImportDropzone.tsx
|
||||
*
|
||||
* Drag & drop upload zone for SKOS .ttl / .rdf files.
|
||||
* Styled for the Palantir dark theme.
|
||||
*/
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { UploadCloud, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { useImportVocabulary } from './queries';
|
||||
import type { ImportResponse } from './types';
|
||||
|
||||
export const ImportDropzone: React.FC = () => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportResponse | null>(null);
|
||||
const importMutation = useImportVocabulary();
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
const selectedFile = acceptedFiles[0];
|
||||
setFile(selectedFile);
|
||||
setImportResult(null);
|
||||
|
||||
importMutation.mutate(selectedFile, {
|
||||
onSuccess: (data) => {
|
||||
setImportResult(data);
|
||||
setTimeout(() => {
|
||||
setFile(null);
|
||||
setImportResult(null);
|
||||
}, 4000);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("Upload failed:", err);
|
||||
setFile(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [importMutation]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'text/turtle': ['.ttl'],
|
||||
'application/rdf+xml': ['.rdf', '.owl']
|
||||
},
|
||||
maxFiles: 1
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
border: `2px dashed ${isDragActive ? '#58a6ff' : 'rgba(88,166,255,0.25)'}`,
|
||||
backgroundColor: isDragActive ? 'rgba(88,166,255,0.06)' : 'rgba(0,0,0,0.2)',
|
||||
borderRadius: 8,
|
||||
padding: '16px 12px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{importMutation.isPending ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: '#8b949e' }}>
|
||||
<Loader2 className="animate-spin" size={20} style={{ marginBottom: 6 }} />
|
||||
<span style={{ fontSize: 12 }}>Uploading {file?.name}…</span>
|
||||
</div>
|
||||
) : importResult ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: '#3fb950' }}>
|
||||
<CheckCircle2 size={20} style={{ marginBottom: 6 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 500 }}>Import Successful!</span>
|
||||
<span style={{ fontSize: 11, marginTop: 2, color: '#56d364' }}>
|
||||
+{importResult.nodes_added} concepts · +{importResult.edges_added} links
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: '#8b949e' }}>
|
||||
<UploadCloud size={20} style={{ marginBottom: 6, color: isDragActive ? '#58a6ff' : '#484f58' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: '#c9d1d9' }}>
|
||||
{isDragActive ? "Drop here…" : "Import Vocabulary"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, marginTop: 2 }}>.ttl or .rdf</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{importMutation.isError && (
|
||||
<p style={{ color: '#f85149', fontSize: 11, marginTop: 6, textAlign: 'center' }}>
|
||||
Upload failed. Check console.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* src/workspaces/VocabularyWorkspace/PropertyPanel.tsx
|
||||
*
|
||||
* Detail panel for a selected SKOS concept — shows preferred label,
|
||||
* URI, alt labels, and description.
|
||||
*/
|
||||
import type { ConceptNode } from './types';
|
||||
|
||||
interface PropertyPanelProps {
|
||||
concept: ConceptNode | null;
|
||||
}
|
||||
|
||||
export function PropertyPanel({ concept }: PropertyPanelProps) {
|
||||
if (!concept) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', height: '100%', color: '#8b949e',
|
||||
textAlign: 'center', padding: 40,
|
||||
}}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||
strokeLinejoin="round" style={{ marginBottom: 16, opacity: 0.4 }}>
|
||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" />
|
||||
</svg>
|
||||
<p style={{ fontSize: 15, fontWeight: 500, marginBottom: 4 }}>No concept selected</p>
|
||||
<p style={{ fontSize: 13 }}>Click a concept in the tree to view its properties.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 28, overflowY: 'auto', height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div style={{ borderBottom: '1px solid rgba(88,166,255,0.2)', paddingBottom: 20, marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||
background: '#d2a8ff', boxShadow: '0 0 8px rgba(210,168,255,0.6)',
|
||||
}} />
|
||||
<span style={{ color: '#d2a8ff', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
SKOS Concept
|
||||
</span>
|
||||
</div>
|
||||
<h2 style={{ margin: 0, color: '#fff', fontSize: 22, fontWeight: 700, wordBreak: 'break-word' }}>
|
||||
{concept.pref_label}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* URI Badge */}
|
||||
<div style={{
|
||||
marginBottom: 20, padding: '8px 14px',
|
||||
background: 'rgba(88,166,255,0.08)',
|
||||
border: '1px solid rgba(88,166,255,0.2)',
|
||||
borderRadius: 6, fontSize: 13, color: '#79c0ff',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{concept.uri}
|
||||
</div>
|
||||
|
||||
{/* Alt Labels */}
|
||||
{concept.alt_labels && concept.alt_labels.length > 0 && (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h4 style={{
|
||||
color: '#8b949e', fontSize: 12, textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em', marginBottom: 10,
|
||||
}}>
|
||||
Alternative Labels
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{concept.alt_labels.map((lbl, i) => (
|
||||
<span key={i} style={{
|
||||
background: '#21262d', color: '#c9d1d9',
|
||||
padding: '4px 10px', borderRadius: 4, fontSize: 13,
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
{lbl}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Children summary */}
|
||||
<section style={{
|
||||
background: 'rgba(0,0,0,0.2)', padding: 16, borderRadius: 8,
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<h4 style={{
|
||||
color: '#8b949e', fontSize: 12, textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em', marginBottom: 10,
|
||||
}}>
|
||||
Narrower Concepts
|
||||
</h4>
|
||||
{concept.children && concept.children.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{concept.children.map((child) => (
|
||||
<span key={child.uri} style={{
|
||||
color: '#c9d1d9', fontSize: 13, padding: '6px 10px',
|
||||
background: 'rgba(88,166,255,0.06)', borderRadius: 4,
|
||||
border: '1px solid rgba(88,166,255,0.12)',
|
||||
}}>
|
||||
{child.pref_label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ margin: 0, color: '#484f58', fontStyle: 'italic', fontSize: 13 }}>
|
||||
Leaf concept — no narrower concepts.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* src/workspaces/VocabularyWorkspace/Sidebar.tsx
|
||||
*
|
||||
* Left sidebar for the Vocabulary workspace.
|
||||
* - Lists available SKOS ConceptSchemes
|
||||
* - Shows the concept hierarchy tree for the active scheme
|
||||
* - Includes the import dropzone
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useVocabularies, useConceptHierarchy } from './queries';
|
||||
import { ConceptTree } from './ConceptTree';
|
||||
import { ImportDropzone } from './ImportDropzone';
|
||||
import type { ConceptNode, VocabularyScheme } from './types';
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectConcept: (concept: ConceptNode) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ onSelectConcept }: SidebarProps) {
|
||||
const { data: schemes = [], isLoading: schemesLoading } = useVocabularies();
|
||||
const [activeScheme, setActiveScheme] = useState<string | undefined>();
|
||||
|
||||
// Auto-select first scheme
|
||||
const selectedSchemeUri = activeScheme ?? schemes[0]?.uri;
|
||||
|
||||
const {
|
||||
data: hierarchy = [],
|
||||
isLoading: treeLoading,
|
||||
} = useConceptHierarchy(selectedSchemeUri);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 340, display: 'flex', flexDirection: 'column',
|
||||
borderRight: '1px solid rgba(88,166,255,0.15)',
|
||||
backgroundColor: '#010409', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px 20px 16px',
|
||||
borderBottom: '1px solid rgba(88,166,255,0.15)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: 18, color: '#c9d1d9', margin: '0 0 4px 0', fontWeight: 600 }}>
|
||||
Ontology & Vocabulary
|
||||
</h2>
|
||||
<p style={{ color: '#8b949e', fontSize: 13, margin: 0 }}>
|
||||
{schemes.length
|
||||
? `${schemes.length} vocabulary scheme${schemes.length > 1 ? 's' : ''}`
|
||||
: 'No vocabularies loaded'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scheme selector */}
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(88,166,255,0.1)' }}>
|
||||
{schemesLoading ? (
|
||||
<div style={{ color: '#8b949e', fontSize: 13 }}>Loading schemes…</div>
|
||||
) : schemes.length === 0 ? (
|
||||
<div style={{ color: '#484f58', fontSize: 13, fontStyle: 'italic' }}>
|
||||
No schemes found. Import a .ttl or .rdf file below.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedSchemeUri || ''}
|
||||
onChange={(e) => setActiveScheme(e.target.value)}
|
||||
style={{
|
||||
width: '100%', appearance: 'none',
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
border: '1px solid rgba(88,166,255,0.2)',
|
||||
color: '#c9d1d9', padding: '8px 12px',
|
||||
borderRadius: 6, fontSize: 13, cursor: 'pointer',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{schemes.map((s: VocabularyScheme) => (
|
||||
<option key={s.uri} value={s.uri} style={{ background: '#0d1117' }}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Concept Tree */}
|
||||
<div style={{
|
||||
flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{treeLoading ? (
|
||||
<div style={{ padding: 20, color: '#8b949e', fontSize: 13, textAlign: 'center' }}>
|
||||
Loading hierarchy…
|
||||
</div>
|
||||
) : hierarchy.length === 0 ? (
|
||||
<div style={{ padding: 20, color: '#484f58', fontSize: 13, textAlign: 'center', fontStyle: 'italic' }}>
|
||||
{selectedSchemeUri
|
||||
? 'No concepts found in this scheme.'
|
||||
: 'Select a scheme to browse concepts.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ConceptTree data={hierarchy} onSelectConcept={onSelectConcept} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Import area */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid rgba(88,166,255,0.15)',
|
||||
}}>
|
||||
<ImportDropzone />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx
|
||||
*
|
||||
* Main Vocabulary workspace — composes the Sidebar (scheme list + tree + import)
|
||||
* and the PropertyPanel (concept details) into a responsive two-column layout.
|
||||
*
|
||||
* All data fetching is handled by TanStack Query hooks in `queries.ts`.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { PropertyPanel } from './PropertyPanel';
|
||||
import type { ConceptNode } from './types';
|
||||
|
||||
const THEME_CSS = `
|
||||
.vocab-workspace {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0d1117;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vocab-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vocab-main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vocab-detail-glass {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.15);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.04);
|
||||
border-radius: 14px;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export function VocabularyWorkspace() {
|
||||
const [selectedConcept, setSelectedConcept] = useState<ConceptNode | null>(null);
|
||||
|
||||
return (
|
||||
<div className="vocab-workspace">
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Left: Sidebar with scheme list + concept tree + import */}
|
||||
<Sidebar onSelectConcept={setSelectedConcept} />
|
||||
|
||||
{/* Right: Concept detail panel */}
|
||||
<div className="vocab-main">
|
||||
{/* Decorative background gradient */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||
background: 'radial-gradient(ellipse at top right, rgba(210,168,255,0.04), transparent 60%)',
|
||||
}} />
|
||||
|
||||
<div className="vocab-main-content" style={{ padding: 32 }}>
|
||||
<div className="vocab-detail-glass">
|
||||
<PropertyPanel concept={selectedConcept} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// queries.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { VocabularyScheme, ConceptNode, ImportResponse } from './types';
|
||||
|
||||
const fetchSchemes = async (): Promise<VocabularyScheme[]> => {
|
||||
const res = await fetch('/api/vocabulary/schemes');
|
||||
if (!res.ok) throw new Error('Failed to fetch vocabularies');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const fetchHierarchy = async (schemeUri: string): Promise<ConceptNode[]> => {
|
||||
const res = await fetch(`/api/vocabulary/hierarchy?scheme=${encodeURIComponent(schemeUri)}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch hierarchy');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Updated to return the ImportResponse
|
||||
const importVocabulary = async (file: File): Promise<ImportResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/vocabulary/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to import vocabulary');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const useVocabularies = () => {
|
||||
return useQuery({
|
||||
queryKey: ['vocabularies'],
|
||||
queryFn: fetchSchemes
|
||||
});
|
||||
};
|
||||
|
||||
export const useConceptHierarchy = (schemeUri: string | undefined) => {
|
||||
return useQuery({
|
||||
queryKey: ['hierarchy', schemeUri],
|
||||
queryFn: () => fetchHierarchy(schemeUri!),
|
||||
enabled: !!schemeUri,
|
||||
});
|
||||
};
|
||||
|
||||
export const useImportVocabulary = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: importVocabulary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vocabularies'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface VocabularyScheme {
|
||||
uri: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ConceptNode {
|
||||
uri: string;
|
||||
pref_label: string;
|
||||
alt_labels: string[];
|
||||
description?: string;
|
||||
notation?: string;
|
||||
scheme_uri?: string;
|
||||
parent_uri?: string;
|
||||
children?: ConceptNode[] | null;
|
||||
}
|
||||
|
||||
export interface ImportResponse {
|
||||
status: string;
|
||||
filename?: string | null;
|
||||
nodes_added: number;
|
||||
edges_added: number;
|
||||
format?: string;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
batchMergeEdges,
|
||||
batchMergeNodes,
|
||||
clearGraph,
|
||||
} from "../src/store/graphStore.ts";
|
||||
import {
|
||||
checkGroupedViewAvailability,
|
||||
resolveDisplayGraph,
|
||||
resolveGroupedDisplayNodeId,
|
||||
resolveGroupedDisplayStateSnapshot,
|
||||
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||
|
||||
function addNode(id: string, semanticGroup = "entity") {
|
||||
batchMergeNodes([
|
||||
{
|
||||
id,
|
||||
attributes: {
|
||||
label: id,
|
||||
content: id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 8,
|
||||
color: "#63E6FF",
|
||||
baseColor: "#63E6FF",
|
||||
nodeType: semanticGroup,
|
||||
semanticGroup,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function addEdge(id: string, source: string, target: string, weight = 1) {
|
||||
batchMergeEdges([
|
||||
{
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
attributes: {
|
||||
edgeType: "related_to",
|
||||
weight,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
test("resolveDisplayGraph bundles parallel edges in full view", () => {
|
||||
addNode("a");
|
||||
addNode("b");
|
||||
addEdge("e1", "a", "b", 1);
|
||||
addEdge("e2", "a", "b", 2);
|
||||
|
||||
const { graph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
|
||||
assert.equal(graph.size, 1);
|
||||
|
||||
const edgeId = graph.edges()[0];
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as {
|
||||
isAggregated?: boolean;
|
||||
aggregateCount?: number;
|
||||
rawEdgeIds?: string[];
|
||||
bundleKind?: string;
|
||||
};
|
||||
|
||||
assert.equal(attrs.isAggregated, true);
|
||||
assert.equal(attrs.aggregateCount, 2);
|
||||
assert.deepEqual(new Set(attrs.rawEdgeIds ?? []), new Set(["e1", "e2"]));
|
||||
assert.equal(attrs.bundleKind, "parallel");
|
||||
});
|
||||
|
||||
test("resolveDisplayGraph collapse keeps path neighbor visible", () => {
|
||||
addNode("center");
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const neighbor = `n${index}`;
|
||||
addNode(neighbor);
|
||||
addEdge(`edge-${index}`, "center", neighbor, 1);
|
||||
}
|
||||
|
||||
const { state } = resolveDisplayGraph("center", ["center", "n9"], [], "full", {
|
||||
aggregationEnabled: false,
|
||||
collapsedNeighborhoodNodeIds: ["center"],
|
||||
});
|
||||
|
||||
assert.equal(state.selectedRootNodeId, "center");
|
||||
assert.equal(state.selectedVisibleNeighborIds.includes("n9"), true);
|
||||
assert.equal(state.selectedVisibleNeighborIds.length, 9);
|
||||
assert.equal(state.selectedCollapsedNeighborIds.length, 1);
|
||||
});
|
||||
|
||||
test("resolveDisplayGraph grouped view emits community nodes and edges", () => {
|
||||
const left = ["a1", "a2", "a3", "a4"];
|
||||
const right = ["b1", "b2", "b3", "b4"];
|
||||
|
||||
[...left, ...right].forEach((nodeId, index) => {
|
||||
addNode(nodeId, index < left.length ? "left" : "right");
|
||||
});
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
for (let j = 0; j < left.length; j += 1) {
|
||||
if (i !== j) {
|
||||
addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < right.length; i += 1) {
|
||||
for (let j = 0; j < right.length; j += 1) {
|
||||
if (i !== j) {
|
||||
addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addEdge("bridge-1", "a1", "b1", 0.1);
|
||||
addEdge("bridge-2", "a2", "b2", 0.1);
|
||||
|
||||
const { graph, state } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||
|
||||
assert.equal(state.groupedViewAvailable, true);
|
||||
|
||||
const communityNodes = graph.nodes().filter((nodeId) => nodeId.startsWith("__community__"));
|
||||
assert.ok(communityNodes.length >= 2);
|
||||
|
||||
const hasCommunityEdge = graph
|
||||
.edges()
|
||||
.map((edgeId) => graph.getEdgeAttributes(edgeId) as { bundleKind?: string; isAggregated?: boolean; aggregateCount?: number })
|
||||
.some((attrs) => attrs.bundleKind === "community" && attrs.isAggregated === true && Number(attrs.aggregateCount ?? 0) > 0);
|
||||
|
||||
assert.equal(hasCommunityEdge, true);
|
||||
});
|
||||
|
||||
// ── resolveGroupedDisplayNodeId ──────────────────────────────────────────────
|
||||
|
||||
test("resolveGroupedDisplayNodeId returns null for empty nodeId", () => {
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||
assert.equal(resolveGroupedDisplayNodeId(displayGraph, ""), null);
|
||||
});
|
||||
|
||||
test("resolveGroupedDisplayNodeId returns nodeId when it exists directly in display graph", () => {
|
||||
addNode("x");
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||
assert.equal(resolveGroupedDisplayNodeId(displayGraph, "x"), "x");
|
||||
});
|
||||
|
||||
test("resolveGroupedDisplayNodeId resolves base node to its community node", () => {
|
||||
const left = ["a1", "a2", "a3", "a4"];
|
||||
const right = ["b1", "b2", "b3", "b4"];
|
||||
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
for (let j = 0; j < left.length; j += 1) {
|
||||
if (i !== j) addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < right.length; i += 1) {
|
||||
for (let j = 0; j < right.length; j += 1) {
|
||||
if (i !== j) addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
|
||||
}
|
||||
}
|
||||
addEdge("bridge-1", "a1", "b1", 0.1);
|
||||
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||
const communityNodes = displayGraph.nodes().filter((n) => n.startsWith("__community__"));
|
||||
assert.ok(communityNodes.length >= 2, "expected community nodes");
|
||||
|
||||
const resolved = resolveGroupedDisplayNodeId(displayGraph, "a1");
|
||||
assert.ok(resolved !== null, "should resolve a1 to a community node");
|
||||
assert.ok(resolved!.startsWith("__community__"), "resolved id should be a community node");
|
||||
});
|
||||
|
||||
// ── resolveGroupedDisplayStateSnapshot ──────────────────────────────────────
|
||||
|
||||
test("resolveGroupedDisplayStateSnapshot returns none-kind when no node selected", () => {
|
||||
addNode("p");
|
||||
addNode("q");
|
||||
addEdge("e1", "p", "q");
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "", {
|
||||
groupedViewAvailable: true,
|
||||
groupedViewReason: null,
|
||||
});
|
||||
assert.equal(state.selectedNodeKind, "none");
|
||||
assert.equal(state.selectedRootNodeId, null);
|
||||
});
|
||||
|
||||
test("resolveGroupedDisplayStateSnapshot maps selected base node to community in grouped graph", () => {
|
||||
const left = ["c1", "c2", "c3", "c4"];
|
||||
const right = ["d1", "d2", "d3", "d4"];
|
||||
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
for (let j = 0; j < left.length; j += 1) {
|
||||
if (i !== j) addEdge(`lc-${edgeIndex++}`, left[i], left[j], 3);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < right.length; i += 1) {
|
||||
for (let j = 0; j < right.length; j += 1) {
|
||||
if (i !== j) addEdge(`rc-${edgeIndex++}`, right[i], right[j], 3);
|
||||
}
|
||||
}
|
||||
addEdge("bridge-c1", "c1", "d1", 0.1);
|
||||
addEdge("bridge-c2", "c2", "d2", 0.1);
|
||||
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "c1", {
|
||||
groupedViewAvailable: true,
|
||||
groupedViewReason: null,
|
||||
selectedNodeKind: "grouped",
|
||||
});
|
||||
|
||||
assert.ok(state.selectedRootNodeId !== null, "should resolve to a community node");
|
||||
assert.ok(state.selectedRootNodeId!.startsWith("__community__"), "root should be a community node");
|
||||
assert.equal(state.groupedViewAvailable, true);
|
||||
});
|
||||
|
||||
// ── checkGroupedViewAvailability ─────────────────────────────────────────────
|
||||
|
||||
test("checkGroupedViewAvailability returns unavailable on empty graph", () => {
|
||||
const result = checkGroupedViewAvailability();
|
||||
assert.equal(result.available, false);
|
||||
assert.ok(typeof result.reason === "string" && result.reason.length > 0);
|
||||
});
|
||||
|
||||
test("checkGroupedViewAvailability returns available when communities exist", () => {
|
||||
const left = ["e1", "e2", "e3", "e4"];
|
||||
const right = ["f1", "f2", "f3", "f4"];
|
||||
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
for (let j = 0; j < left.length; j += 1) {
|
||||
if (i !== j) addEdge(`le-${edgeIndex++}`, left[i], left[j], 3);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < right.length; i += 1) {
|
||||
for (let j = 0; j < right.length; j += 1) {
|
||||
if (i !== j) addEdge(`re-${edgeIndex++}`, right[i], right[j], 3);
|
||||
}
|
||||
}
|
||||
addEdge("bridge-e1", "e1", "f1", 0.1);
|
||||
|
||||
const result = checkGroupedViewAvailability();
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.reason, null);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import Graph from "graphology";
|
||||
|
||||
import { curveGroupForPair, pairRegistryKey } from "../src/store/edgePairKeys.js";
|
||||
|
||||
function normalizeParallelMetadataForPair(graph, source, target) {
|
||||
const edgeIds = [];
|
||||
graph.forEachDirectedEdge(source, target, (edgeId) => {
|
||||
edgeIds.push(String(edgeId));
|
||||
});
|
||||
|
||||
const pairCount = edgeIds.length;
|
||||
const familyCounts = new Map();
|
||||
edgeIds.forEach((edgeId) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId);
|
||||
const familyId = String(attrs.familyId || edgeId);
|
||||
familyCounts.set(familyId, (familyCounts.get(familyId) ?? 0) + 1);
|
||||
});
|
||||
|
||||
edgeIds
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.forEach((edgeId, index) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId);
|
||||
const familyId = String(attrs.familyId || edgeId);
|
||||
graph.mergeEdgeAttributes(edgeId, {
|
||||
edgeId,
|
||||
familyId,
|
||||
sourceId: source,
|
||||
targetId: target,
|
||||
isParallelPair: pairCount > 1,
|
||||
parallelIndex: index,
|
||||
parallelCount: pairCount,
|
||||
familySize: familyCounts.get(familyId) ?? 1,
|
||||
curveGroup: curveGroupForPair(source, target),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function batchMergeEdges(graph, edges) {
|
||||
const touchedPairs = new Map();
|
||||
|
||||
for (const { id, familyId, source, target, attributes } of edges) {
|
||||
const edgeId = String(attributes.edgeId || id);
|
||||
const resolvedFamilyId = String(attributes.familyId || familyId || edgeId);
|
||||
|
||||
if (graph.hasNode(source) && graph.hasNode(target)) {
|
||||
graph.mergeDirectedEdgeWithKey(edgeId, source, target, {
|
||||
...attributes,
|
||||
edgeId,
|
||||
familyId: resolvedFamilyId,
|
||||
sourceId: source,
|
||||
targetId: target,
|
||||
});
|
||||
touchedPairs.set(pairRegistryKey(source, target), { source, target });
|
||||
}
|
||||
}
|
||||
|
||||
touchedPairs.forEach(({ source, target }) => {
|
||||
normalizeParallelMetadataForPair(graph, source, target);
|
||||
});
|
||||
}
|
||||
|
||||
test("structured pair keys support node ids containing double colons", () => {
|
||||
const graph = new Graph({ type: "directed", multi: true, allowSelfLoops: false });
|
||||
|
||||
graph.addNode("gene/protein::10");
|
||||
graph.addNode("gene/protein::472");
|
||||
graph.addNode("gene/protein::500");
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
batchMergeEdges(graph, [
|
||||
{
|
||||
id: "edge-a",
|
||||
familyId: "family-1",
|
||||
source: "gene/protein::10",
|
||||
target: "gene/protein::472",
|
||||
attributes: { edgeType: "protein_protein", weight: 1, properties: {} },
|
||||
},
|
||||
{
|
||||
id: "edge-b",
|
||||
familyId: "family-2",
|
||||
source: "gene/protein::10",
|
||||
target: "gene/protein::472",
|
||||
attributes: { edgeType: "protein_protein", weight: 1, properties: {} },
|
||||
},
|
||||
{
|
||||
id: "edge-c",
|
||||
familyId: "family-3",
|
||||
source: "gene/protein::10",
|
||||
target: "gene/protein::500",
|
||||
attributes: { edgeType: "protein_protein", weight: 1, properties: {} },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const edgeA = graph.getEdgeAttributes("edge-a");
|
||||
const edgeB = graph.getEdgeAttributes("edge-b");
|
||||
const edgeC = graph.getEdgeAttributes("edge-c");
|
||||
|
||||
assert.equal(edgeA.sourceId, "gene/protein::10");
|
||||
assert.equal(edgeA.targetId, "gene/protein::472");
|
||||
assert.equal(edgeA.parallelCount, 2);
|
||||
assert.equal(edgeB.parallelCount, 2);
|
||||
assert.equal(edgeA.isParallelPair, true);
|
||||
assert.equal(edgeB.isParallelPair, true);
|
||||
assert.equal(edgeC.parallelCount, 1);
|
||||
assert.equal(edgeC.isParallelPair, false);
|
||||
assert.equal(edgeA.curveGroup, JSON.stringify(["gene/protein::10", "gene/protein::472"]));
|
||||
assert.equal(edgeC.curveGroup, JSON.stringify(["gene/protein::10", "gene/protein::500"]));
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
base: '/',
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, '../semantica/static'),
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 650,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const normalizedId = id.replaceAll('\\', '/')
|
||||
|
||||
if (!normalizedId.includes('node_modules')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedId.includes('/node_modules/sigma/') ||
|
||||
normalizedId.includes('/node_modules/graphology/') ||
|
||||
normalizedId.includes('/node_modules/graphology-layout-forceatlas2/')
|
||||
) {
|
||||
return 'graph-vendor'
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedId.includes('/node_modules/vis-data/') ||
|
||||
normalizedId.includes('/node_modules/vis-timeline/')
|
||||
) {
|
||||
return 'timeline-vendor'
|
||||
}
|
||||
|
||||
if (normalizedId.includes('/node_modules/@tanstack/react-query/')) {
|
||||
return 'query-vendor'
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
# Semantica × OpenClaw Integration
|
||||
|
||||
Connect [OpenClaw](https://openclaw.ai) — the open-source personal AI agent — to Semantica's full knowledge-graph and decision-intelligence stack.
|
||||
|
||||
Two integration paths are available:
|
||||
|
||||
| Path | When to use |
|
||||
|---|---|
|
||||
| **MCP (recommended)** | OpenClaw Gateway is running; zero extra code needed |
|
||||
| **REST / native tool** | Embedding Semantica directly in a SOUL.md agent config |
|
||||
|
||||
---
|
||||
|
||||
## Path 1 — MCP Server (recommended)
|
||||
|
||||
### 1. Start the Semantica MCP server
|
||||
|
||||
```bash
|
||||
python -m semantica.mcp_server
|
||||
```
|
||||
|
||||
### 2. Add to `mcporter.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "semantica.mcp_server"],
|
||||
"transport": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Restart the OpenClaw Gateway
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
All **12 Semantica tools** are now available to any OpenClaw agent:
|
||||
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `extract_entities` | Named entity recognition from text |
|
||||
| `extract_relations` | Relation / triplet extraction from text |
|
||||
| `record_decision` | Record a decision with causal links |
|
||||
| `query_decisions` | Search recorded decisions |
|
||||
| `find_precedents` | Find past decisions similar to a query |
|
||||
| `get_causal_chain` | Trace cause-effect chains from a node |
|
||||
| `add_entity` | Add a node to the knowledge graph |
|
||||
| `add_relationship` | Add an edge between two nodes |
|
||||
| `run_reasoning` | Forward-chain rules over facts |
|
||||
| `get_graph_analytics` | Centrality, communities, topology stats |
|
||||
| `export_graph` | Export graph (JSON, RDF, GraphML, …) |
|
||||
| `get_graph_summary` | High-level graph overview |
|
||||
|
||||
**3 resources** are also exposed: `semantica://graph/summary`, `semantica://decisions/list`, `semantica://schema/info`.
|
||||
|
||||
---
|
||||
|
||||
## Path 2 — Native Tool (REST)
|
||||
|
||||
Use `OpenClawKGTool` when you prefer a direct Python integration without the MCP gateway.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
pip install semantica[openclaw] # pulls in 'requests'
|
||||
```
|
||||
|
||||
### Quick start
|
||||
|
||||
```python
|
||||
from integrations.openclaw import OpenClawKGTool
|
||||
|
||||
tool = OpenClawKGTool(base_url="http://localhost:8000")
|
||||
|
||||
# Extract knowledge from text
|
||||
entities = tool.extract_entities("OpenClaw is an open-source AI agent built in Python.")
|
||||
relations = tool.extract_relations("Alice manages the OpenClaw project at Hawksight.")
|
||||
|
||||
# Record and query decisions
|
||||
tool.record_decision("Deploy model v2 to production", context="latency improved by 40%")
|
||||
precedents = tool.find_precedents("roll back production deployment")
|
||||
|
||||
# Graph analytics
|
||||
summary = tool.get_graph_summary()
|
||||
analytics = tool.get_graph_analytics()
|
||||
|
||||
# Export
|
||||
ttl = tool.export_graph(fmt="ttl")
|
||||
```
|
||||
|
||||
### Generate `mcporter.json` programmatically
|
||||
|
||||
```python
|
||||
from integrations.openclaw import OpenClawMCPConfig
|
||||
|
||||
cfg = OpenClawMCPConfig()
|
||||
print(cfg.to_json()) # → paste into mcporter.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SOUL.md agent snippet
|
||||
|
||||
Add Semantica to any OpenClaw agent by referencing the tool in your `SOUL.md`:
|
||||
|
||||
```markdown
|
||||
## Tools
|
||||
|
||||
- name: semantica_kg
|
||||
description: >
|
||||
Semantica knowledge-graph tool. Supports entity extraction, decision
|
||||
recording, graph querying, causal chain analysis, reasoning, and
|
||||
multi-format export.
|
||||
endpoint: http://localhost:8000
|
||||
auth: none
|
||||
|
||||
## Instructions
|
||||
|
||||
You have access to `semantica_kg`. Use it to:
|
||||
- Extract entities and relations from any text the user provides.
|
||||
- Record important decisions and retrieve precedents before recommending actions.
|
||||
- Run graph analytics and export results when the user asks for a summary.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- `pip install semantica` (core)
|
||||
- `pip install semantica[openclaw]` (adds `requests` for the REST path)
|
||||
- OpenClaw ≥ latest — [openclaw.ai](https://openclaw.ai)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Semantica × OpenClaw Integration
|
||||
==================================
|
||||
|
||||
First-class integration between the Semantica semantic intelligence stack and
|
||||
`OpenClaw <https://openclaw.ai>`_ — the open-source personal AI agent platform.
|
||||
|
||||
OpenClaw connects to external tools via MCP (Model Context Protocol). This
|
||||
integration exposes the full Semantica MCP surface (12 tools, 3 resources) to
|
||||
any OpenClaw agent and also ships a lightweight ``OpenClawKGTool`` that can be
|
||||
dropped directly into an OpenClaw SOUL.md tool-list as a native tool.
|
||||
|
||||
Public surface
|
||||
--------------
|
||||
OpenClawKGTool — Thin wrapper around the Semantica REST API usable as an
|
||||
OpenClaw native tool (no MCP gateway required)
|
||||
OpenClawMCPConfig — Helper that emits the ``mcporter.json`` snippet needed to
|
||||
wire Semantica's MCP server into an OpenClaw gateway
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
pip install semantica
|
||||
|
||||
>>> from integrations.openclaw import OpenClawKGTool, OpenClawMCPConfig
|
||||
>>> print(OpenClawMCPConfig().to_json()) # paste into mcporter.json
|
||||
>>> tool = OpenClawKGTool(base_url="http://localhost:8000")
|
||||
>>> result = tool.extract("OpenClaw is an open-source AI agent framework.")
|
||||
|
||||
MCP quick start
|
||||
---------------
|
||||
Run the Semantica MCP server once::
|
||||
|
||||
python -m semantica.mcp_server
|
||||
|
||||
Then add the printed config snippet to your OpenClaw ``mcporter.json`` and
|
||||
restart the OpenClaw Gateway::
|
||||
|
||||
openclaw gateway restart
|
||||
|
||||
All 12 Semantica tools are then available as native OpenClaw agent tools.
|
||||
|
||||
Compatibility
|
||||
-------------
|
||||
Requires ``semantica >= 0.3.0``. The MCP path requires ``python >= 3.8`` and
|
||||
a running ``semantica.mcp_server`` instance. The REST path requires a running
|
||||
``semantica.server`` instance (``python -m semantica.server``, port 8000 by
|
||||
default).
|
||||
"""
|
||||
|
||||
from .mcp_tool import OpenClawKGTool, OpenClawMCPConfig
|
||||
|
||||
__all__ = [
|
||||
"OpenClawKGTool",
|
||||
"OpenClawMCPConfig",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
OpenClaw ↔ Semantica bridge
|
||||
============================
|
||||
|
||||
Two integration paths:
|
||||
|
||||
1. **MCP (recommended)** — ``OpenClawMCPConfig`` emits the ``mcporter.json``
|
||||
snippet that wires Semantica's MCP server into the OpenClaw Gateway.
|
||||
All 12 Semantica MCP tools become native OpenClaw agent tools with no
|
||||
extra code.
|
||||
|
||||
2. **REST** — ``OpenClawKGTool`` is a plain Python class that calls the
|
||||
Semantica REST API (port 8000) and can be registered as an OpenClaw
|
||||
native tool via SOUL.md ``tools:`` entries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP config helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OpenClawMCPConfig:
|
||||
"""
|
||||
Generates the ``mcporter.json`` entry needed to connect Semantica's MCP
|
||||
server to the OpenClaw Gateway.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
server_command:
|
||||
Shell command used to launch the Semantica MCP server.
|
||||
Defaults to ``"python -m semantica.mcp_server"``.
|
||||
transport:
|
||||
MCP transport protocol. OpenClaw supports ``"stdio"`` (default)
|
||||
and ``"sse"``.
|
||||
name:
|
||||
Key used in ``mcporter.json``. Defaults to ``"semantica"``.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> cfg = OpenClawMCPConfig()
|
||||
>>> print(cfg.to_json())
|
||||
# → paste into ~/.openclaw/mcporter.json, then:
|
||||
# → openclaw gateway restart
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_command: str = "python -m semantica.mcp_server",
|
||||
transport: str = "stdio",
|
||||
name: str = "semantica",
|
||||
) -> None:
|
||||
self.server_command = server_command
|
||||
self.transport = transport
|
||||
self.name = name
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the config as a plain dict."""
|
||||
parts = self.server_command.split()
|
||||
return {
|
||||
"mcpServers": {
|
||||
self.name: {
|
||||
"command": parts[0],
|
||||
"args": parts[1:],
|
||||
"transport": self.transport,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
"""Return the config as a JSON string."""
|
||||
return json.dumps(self.to_dict(), indent=indent)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"OpenClawMCPConfig(name={self.name!r}, transport={self.transport!r})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REST-based native tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OpenClawKGTool:
|
||||
"""
|
||||
A Semantica knowledge-graph tool callable from an OpenClaw agent.
|
||||
|
||||
Wraps the Semantica REST API so that an OpenClaw agent configured with
|
||||
this tool (via SOUL.md ``tools:`` entries or programmatic registration)
|
||||
can extract entities, record decisions, query the graph, and more —
|
||||
without requiring the MCP gateway.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
base_url:
|
||||
Base URL of the running Semantica REST server.
|
||||
Defaults to ``"http://localhost:8000"``.
|
||||
timeout:
|
||||
Request timeout in seconds. Defaults to ``30``.
|
||||
|
||||
Notes
|
||||
-----
|
||||
``requests`` is used for HTTP calls. It is listed as an optional
|
||||
dependency under ``semantica[openclaw]``; install it with::
|
||||
|
||||
pip install semantica[openclaw]
|
||||
"""
|
||||
|
||||
TOOL_NAME = "semantica_kg"
|
||||
TOOL_DESCRIPTION = (
|
||||
"Semantica knowledge-graph tool. "
|
||||
"Supports entity extraction, decision recording, graph querying, "
|
||||
"causal chain analysis, reasoning, and multi-format export."
|
||||
)
|
||||
|
||||
def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 30) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self._session: Any = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_session(self) -> Any:
|
||||
if self._session is None:
|
||||
try:
|
||||
import requests
|
||||
self._session = requests.Session()
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"The 'requests' package is required for OpenClawKGTool. "
|
||||
"Install it with: pip install semantica[openclaw]"
|
||||
) from exc
|
||||
return self._session
|
||||
|
||||
def _post(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
session = self._get_session()
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
response = session.post(url, json=payload, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
session = self._get_session()
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
response = session.get(url, params=params or {}, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Extraction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def extract(self, text: str) -> Dict[str, Any]:
|
||||
"""Extract entities and relations from *text*."""
|
||||
return self._post("/extract", {"text": text})
|
||||
|
||||
def extract_entities(self, text: str) -> List[Dict[str, Any]]:
|
||||
"""Return only the entity list from *text*."""
|
||||
result = self.extract(text)
|
||||
return result.get("entities", [])
|
||||
|
||||
def extract_relations(self, text: str) -> List[Dict[str, Any]]:
|
||||
"""Return only the relation list from *text*."""
|
||||
result = self.extract(text)
|
||||
return result.get("relations", [])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph mutation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_entity(self, label: str, entity_type: str = "Entity", **properties: Any) -> Dict[str, Any]:
|
||||
"""Add a node to the knowledge graph."""
|
||||
return self._post("/entities", {"label": label, "type": entity_type, **properties})
|
||||
|
||||
def add_relationship(
|
||||
self,
|
||||
source: str,
|
||||
target: str,
|
||||
relation_type: str,
|
||||
**properties: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add an edge between *source* and *target*."""
|
||||
return self._post(
|
||||
"/relationships",
|
||||
{"source": source, "target": target, "type": relation_type, **properties},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Decisions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_decision(
|
||||
self,
|
||||
decision_text: str,
|
||||
context: Optional[str] = None,
|
||||
**metadata: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Record a decision in the graph."""
|
||||
payload: Dict[str, Any] = {"decision": decision_text}
|
||||
if context:
|
||||
payload["context"] = context
|
||||
payload.update(metadata)
|
||||
return self._post("/decisions", payload)
|
||||
|
||||
def query_decisions(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search recorded decisions."""
|
||||
result = self._get("/decisions/search", {"q": query, "limit": limit})
|
||||
return result.get("decisions", [])
|
||||
|
||||
def find_precedents(self, decision_text: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Find past decisions similar to *decision_text*."""
|
||||
result = self._post("/decisions/precedents", {"decision": decision_text, "top_k": top_k})
|
||||
return result.get("precedents", [])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Analytics & reasoning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_causal_chain(self, node_id: str, depth: int = 3) -> Dict[str, Any]:
|
||||
"""Retrieve the causal chain rooted at *node_id*."""
|
||||
return self._get("/causal-chain", {"node_id": node_id, "depth": depth})
|
||||
|
||||
def run_reasoning(self, rules: List[str], facts: List[str]) -> Dict[str, Any]:
|
||||
"""Run the Semantica forward-chaining reasoner."""
|
||||
return self._post("/reason", {"rules": rules, "facts": facts})
|
||||
|
||||
def get_graph_analytics(self) -> Dict[str, Any]:
|
||||
"""Return graph-level analytics (centrality, communities, etc.)."""
|
||||
return self._get("/analytics")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Export
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def export_graph(self, fmt: str = "json") -> str:
|
||||
"""Export the graph in *fmt* (``json``, ``ttl``, ``graphml``, …)."""
|
||||
result = self._get("/export", {"format": fmt})
|
||||
return result.get("data", "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Summary
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_graph_summary(self) -> Dict[str, Any]:
|
||||
"""Return a high-level summary of the current graph."""
|
||||
return self._get("/graph/summary")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"OpenClawKGTool(base_url={self.base_url!r})"
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
# Semantica MCP Server
|
||||
|
||||
A fully modular [Model Context Protocol](https://modelcontextprotocol.io/) server for the Semantica knowledge graph.
|
||||
Connects Claude Code, Cursor, Windsurf, Cline, Continue, VS Code (GitHub Copilot), and any other MCP-compatible AI tool directly to your Semantica graph.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# From the repo root
|
||||
pip install -e ".[mcp]"
|
||||
|
||||
# Test the server (type a JSON-RPC request, press Enter)
|
||||
python -m mcp
|
||||
```
|
||||
|
||||
Or point your AI tool at it (see per-tool configs below).
|
||||
|
||||
---
|
||||
|
||||
## Transport
|
||||
|
||||
**stdio** — the server reads newline-delimited JSON-RPC 2.0 from `stdin` and writes responses to `stdout`.
|
||||
Log/debug output goes to `stderr` only.
|
||||
|
||||
```
|
||||
python -m mcp [--debug]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tools (17 total)
|
||||
|
||||
### Extraction
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `extract_entities` | Named entity recognition (NER) — people, places, orgs, concepts |
|
||||
| `extract_relations` | Relation extraction + (subject, predicate, object) triplets |
|
||||
| `extract_all` | Full pipeline: NER + coreference + relations + events + triplets |
|
||||
|
||||
### Decision Intelligence
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `record_decision` | Record a decision with context, confidence, causal links |
|
||||
| `query_decisions` | Query decisions by natural language or structured filters |
|
||||
| `find_precedents` | Find past decisions similar to a scenario (hybrid similarity) |
|
||||
| `get_causal_chain` | Trace upstream/downstream causal chain from a decision |
|
||||
| `analyze_decision_impact` | Analyse downstream influence of a decision |
|
||||
|
||||
### Knowledge Graph
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `add_entity` | Add a node/entity to the graph |
|
||||
| `add_relationship` | Add a directed edge between two entities |
|
||||
| `search_graph` | Search nodes by label or ID substring |
|
||||
| `get_graph_summary` | Node/edge counts, decision count, type breakdown |
|
||||
| `get_graph_analytics` | PageRank, betweenness, degree centrality, community detection |
|
||||
|
||||
### Reasoning
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `run_reasoning` | Forward-chaining IF/THEN rules over facts |
|
||||
| `abductive_reasoning` | Generate plausible hypotheses for observations |
|
||||
|
||||
### Export & Provenance
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `export_graph` | Export graph to JSON, CSV, GraphML, Parquet, Turtle, N-Triples, RDF/XML, JSON-LD |
|
||||
| `get_provenance` | Audit history and source lineage for a node |
|
||||
|
||||
---
|
||||
|
||||
## Resources (4 total)
|
||||
|
||||
| URI | Description |
|
||||
|---|---|
|
||||
| `semantica://graph/summary` | Live node/edge counts and type breakdown |
|
||||
| `semantica://decisions/list` | Most recent 50 decisions |
|
||||
| `semantica://schema/info` | Schema version, node/edge types, tool names |
|
||||
| `semantica://ontology/schema` | Full ontology schema |
|
||||
|
||||
---
|
||||
|
||||
## Per-tool configuration
|
||||
|
||||
### Claude Code (`~/.claude/settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp"],
|
||||
"cwd": "/path/to/semantica"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use the plugin bundle:
|
||||
```bash
|
||||
claude mcp add semantica python -m mcp --cwd /path/to/semantica
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Cursor (`~/.cursor/mcp.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp"],
|
||||
"cwd": "/path/to/semantica"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Windsurf (`~/.codeium/windsurf/mcp_config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp"],
|
||||
"cwd": "/path/to/semantica"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Cline (VS Code extension settings)
|
||||
|
||||
In your VS Code `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cline.mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp"],
|
||||
"cwd": "/path/to/semantica"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Continue (`~/.continue/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": [
|
||||
{
|
||||
"name": "semantica",
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp"],
|
||||
"cwd": "/path/to/semantica"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### VS Code (GitHub Copilot) — `.vscode/mcp.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"semantica": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp"],
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Amazon Q Developer
|
||||
|
||||
Add to your Q Developer MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp"],
|
||||
"cwd": "/path/to/semantica"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SEMANTICA_KG_PATH` | *(in-memory)* | Path to persist/load the graph (JSON file) |
|
||||
|
||||
---
|
||||
|
||||
## Package structure
|
||||
|
||||
```
|
||||
mcp/
|
||||
├── __init__.py # Package entry, re-exports SemanticaMCPServer + main
|
||||
├── __main__.py # python -m mcp entry point
|
||||
├── server.py # SemanticaMCPServer class + stdio event loop
|
||||
├── session.py # Lazy ContextGraph singleton (get_graph / reset_graph)
|
||||
├── schemas.py # JSON Schema definitions for all tool inputs
|
||||
├── tools/
|
||||
│ ├── __init__.py # Assembles TOOL_DEFINITIONS list
|
||||
│ ├── extraction.py # NER, relation extraction, full pipeline
|
||||
│ ├── decisions.py # Record, query, precedents, causal chain, impact
|
||||
│ ├── graph.py # Add entity/relationship, search, summary, analytics
|
||||
│ ├── reasoning.py # Forward-chaining rules, abductive hypotheses
|
||||
│ └── export.py # Graph export (multi-format) + provenance
|
||||
└── resources/
|
||||
├── __init__.py # Re-exports RESOURCE_DEFINITIONS + handle_resource_read
|
||||
└── registry.py # URI → handler map for the 4 semantica:// resources
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Semantica MCP Server Package
|
||||
|
||||
A full Model Context Protocol (MCP) server for Semantica — exposes knowledge graph
|
||||
construction, semantic extraction, decision intelligence, reasoning, analytics,
|
||||
and export capabilities as MCP tools and resources.
|
||||
|
||||
Run the server:
|
||||
python -m mcp.server # from repo root
|
||||
python -m semantica.mcp_server # alias inside installed package
|
||||
|
||||
Configure in Claude Desktop, Windsurf, Cline, Continue, VS Code:
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp.server"],
|
||||
"cwd": "/path/to/semantica"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from .server import SemanticaMCPServer, main
|
||||
|
||||
__all__ = ["SemanticaMCPServer", "main"]
|
||||
__version__ = "0.4.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Entry point: python -m mcp.server"""
|
||||
from mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user