Compare commits

..
121 Commits
Author SHA1 Message Date
Mohd KaifandClaude Sonnet 4.6 7884d71e23 feat(explorer): add welcome screen and fix root path Invalid path error (#501)
- Add WelcomeScreen shown on app load; SKE brand button navigates back
- Fix serve_spa: empty root path was hitting dot-guard returning 400
  Invalid path instead of index.html or a welcome JSON response

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:41:13 +05:30
Mohd Kaif ca5f081793 Merge pull request #493 from Hawksight-AI/feat/explorer-grouped-view
Feat/explorer grouped view
2026-04-25 15:44:04 +05:30
6ad1502224 fix(explorer): address grouped view review blockers
- Fix `import.meta.env.DEV` crash in graphSceneState.ts that broke the
  entire test:graph-workspace suite (module load fails in Node.js/tsx)
- Export `resolveGroupedDisplayNodeId` from graphSceneState.ts and
  remove the identical copy in GraphWorkspace.tsx
- Add `checkGroupedViewAvailability` helper (Louvain only, no centrality)
  so grouped view availability can be checked cheaply on every graph change
- Gate full community graph build (`groupedDisplayCandidate`) on
  `viewMode === 'grouped'` to avoid running Louvain + centrality on every
  graph version tick when the user is not in grouped view
- Remove dead ternary in `focusNode` where both branches returned `nodeId`
- Add 7 new tests covering resolveGroupedDisplayNodeId,
  resolveGroupedDisplayStateSnapshot, and checkGroupedViewAvailability

Co-Authored-By: ZohaibHassan16 <109234410+ZohaibHassan16@users.noreply.github.com>
Co-Authored-By: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-25 13:42:15 +05:30
Zohaib Hassnain b010ba68fa Merge origin/main into feat/explorer-grouped-view 2026-04-25 02:54:48 +05:00
Zohaib Hassnain 7c8dfbd3c0 feat(explorer): stabilize and refine grouped graph view 2026-04-25 02:40:51 +05:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 45400c88d3 ci(deps): bump actions/upload-pages-artifact from 3 to 5 (#485)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 13:10:05 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 26f6cdf9e5 docker(deps): bump python from 3.12-slim to 3.14-slim (#466)
Bumps python from 3.12-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 13:07:11 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 96f88594c2 docker(deps): bump node from 20-alpine to 25-alpine (#465)
Bumps node from 20-alpine to 25-alpine.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 25-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 13:03:24 +05:30
Mohd Kaif c0d08c46f7 Merge pull request #486 from Hawksight-AI/fix/graph-motion
Fix explorer zooming and Loading Flicker
2026-04-23 19:13:37 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 5a388d0bcc Potential fix for pull request finding 'Useless conditional'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-23 19:02:31 +05:30
KaifAhmad1andZohaibHassan16 f516aef8fd fix(explorer): address PR #486 review blockers + resolve conflict with main
Merge conflict resolution:
- Kept fix/graph-motion's conditional layout-stop (only in focused mode)
  to preserve live layout motion for derived graphs — the core intent of
  this PR.

Must-fix items resolved:

1. plugin.json — removed "hooks": "./hooks/hooks.json" (re-added by this
   branch, already removed in PR #489 on main as it is auto-loaded).
   Kept "agents": "./agents".

2. Double Louvain per render — added groupedViewAvailable useMemo in
   GraphWorkspace (deps: [graphVersion]) that runs community detection
   once. Passed result into resolveDisplayGraph and resolveDisplayStateSnapshot
   via new groupedViewAvailable option; both functions skip their internal
   computeGraphAnalyticsBase call when the value is pre-supplied.

3. graphVersion in displayState deps — removed graphVersion from the
   displayState memo dep array. displayState now depends on the stable
   boolean groupedViewAvailable, not on every ADD_NODE/ADD_EDGE tick,
   so Louvain no longer re-fires on every WebSocket update.

4. hideLabelsOnMove / hideEdgesOnMove flipped to true — intentional:
   suppressing labels and edges during pan reduces visual noise and is
   part of the flicker-reduction fix described in the PR.

Co-authored-by: ZohaibHassan16 <zohaibhassan1696@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-04-23 18:58:48 +05:30
Mohd Kaif c9a382e676 Merge pull request #487 from Sameer6305/feat/explorer-stabilize-local-graph-interaction
feat(explorer): stabilize local graph interaction
2026-04-23 18:28:45 +05:30
KaifAhmad1andSameer6305 897d950bdc fix(explorer): address PR #487 review blockers
1. Prevent active-but-disabled Focused button by only disabling when
   viewMode is not already "focused" (viewMode !== "focused" && !canActivateFocusedMode).
2. Generalize inspector fallback copy — stale/invalid node IDs are not
   necessarily grouped items, so remove the misleading "Activate Focused
   mode" hint.
3. Move pluginRuntimeRef.current read out of render by converting
   canActivateFocusedMode from useMemo to useState + useEffect, resolving
   two ESLint "cannot access refs during render" errors and the missing
   toolbar-memo dependency warning.

Co-authored-by: Sameer6305 <sskadam6305@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-04-23 18:24:31 +05:30
Mohd Kaif dd8fa17db8 Merge pull request #489 from musicload/fix/local-plugin-install
fix(plugin): make local Claude Code plugin install work out of the box
2026-04-23 11:42:20 +05:30
KaifAhmad1 92801c220e docs(plugin): align Claude install commands with marketplace flow 2026-04-23 11:34:38 +05:30
Serge 738480606c fix(plugin): drop hooks field from plugin.json (auto-loaded)
Claude Code auto-loads hooks/hooks.json. Declaring it explicitly in
manifest.hooks causes: 'Duplicate hooks file detected ... already-loaded'.
Same pattern as agents: manifest should only reference *additional* hook
files beyond the default.
2026-04-22 16:03:00 -04:00
Serge f9e0bcf210 fix(plugin): make local plugin install work out of the box
Two separate schema issues blocked `/plugin marketplace add ./plugins`
followed by `/plugin install semantica@semantica-local`:

1. `marketplace.json` was missing the required top-level `owner` object.
   Claude Code rejects with: `owner: Invalid input: expected object,
   received undefined`.

2. `plugin.json` declared `"agents": "./agents"` (string), but Claude
   Code's manifest schema rejects non-array `agents` with:
   `Validation errors: agents: Invalid input`. Auto-discovery from
   the default `agents/` directory works when the field is omitted,
   provided agents are flat `<name>.md` files with frontmatter (Claude
   Code's subagent convention) rather than `<name>/AGENT.md`
   subdirectories.

Changes:
- add `owner` object to `marketplace.json`
- drop `agents` field from `plugin.json` (falls back to auto-discovery)
- rename `agents/<name>/AGENT.md` -> `agents/<name>.md` (frontmatter
  content is unchanged, just the path)

After this, the documented local-install flow succeeds end-to-end.
2026-04-22 15:50:34 -04:00
Sameer6305 bb0e9f49e3 feat(explorer): stabilize local graph interaction 2026-04-23 00:11:38 +05:30
Zohaib Hassnain f95c1612d5 fix blinking and zooming problem 2026-04-22 03:33:09 +05:00
Zohaib Hassnain 8c202a691e fix(explorer): restore live layout motion for derived graphs 2026-04-22 03:31:36 +05:00
Mohd Kaif 304b82fbd6 Merge pull request #483 from ZohaibHassan16/feat/graph-declutter-and-calm
feat(explorer): calm and structurally declutter graph workspace
2026-04-20 17:50:00 +05:30
KaifAhmad1andZohaib Hassnain 8d2dfaa53c docs(changelog): add PR #483 explorer declutter release notes
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>

Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-04-20 17:33:12 +05:30
KaifAhmad1andZohaib Hassnain 39aaae778f Merge origin/main into feat/graph-declutter-and-calm
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>

Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-04-20 17:20:47 +05:30
KaifAhmad1andZohaib Hassnain 16d628997a test(explorer): cover graph display declutter flows
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>

Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-04-20 17:14:40 +05:30
Mohd Kaif 5e6ad6e87e Merge pull request #482 from liling/main
fix(providers): switch DeepSeekProvider from deepseek SDK to OpenAI c…
2026-04-19 20:16:25 +05:30
Mohd Kaif f6198039fa Merge branch 'main' into main 2026-04-19 20:10:22 +05:30
983f5301e8 fix(providers): switch DeepSeekProvider to OpenAI SDK + fix base_url and verbose_mode (closes #482)
- Replace deepseek.Client with openai.OpenAI(base_url="https://api.deepseek.com/v1")
  in DeepSeekProvider._init_client(); the deepseek PyPI package has no Client class
- Add self.base_url = "https://api.deepseek.com/v1" to DeepSeekProvider.__init__()
  (missing from original PR; caused AttributeError on every instantiation)
- Fix verbose_mode NameError in BaseProvider.generate_typed() instructor path
- Update pyproject.toml: llm-deepseek extra now declares openai>=1.0.0
- Update _init_client warning message to reference openai library
- Add 19 tests in tests/semantic_extract/test_pr482_deepseek_openai.py
- Update CHANGELOG.md

Co-authored-by: liling <liling@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 20:07:44 +05:30
Mohd Kaif 5a852169be Merge pull request #481 from ZohaibHassan16/feat/optimize-search
Feat/optimize search
2026-04-19 19:04:13 +05:30
KaifAhmad1 fe6ca7fccb fix(search-index): restore secondary-scan node ordering and add regression test 2026-04-19 18:46:05 +05:30
Mohd Kaif 66c8431eee Merge branch 'main' into feat/optimize-search 2026-04-19 18:25:28 +05:30
3e2a0a3f3b docs(changelog): add indexed search performance entry (#481, #467)
Co-authored-by: ZohaibHassan16 <zohaibhassan16@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 18:19:00 +05:30
d22a54353a fix(search-index): bisect ops, thread-safe mutation bridge, drop edge upserts
- Replace list.sort() on every upsert with bisect.insort() — O(log n) per
  insert instead of O(n log n); bulk rebuild still sorts once at the end
- Replace list.remove() in remove() with bisect.bisect_left + pop() — O(log n)
  find instead of O(n) scan
- Wrap handle_graph_mutation() index mutations in self._lock — mutation bridge
  fires from a background thread and was racing concurrent search/rebuild calls
- Drop source/target upserts in add_edge() — edges don't change node text so
  the index documents are identical; removes unnecessary cache invalidation
- Sort tag values in _cache_key() — ["a","b"] and ["b","a"] now share a cache
  entry since _passes_filters() uses set intersection (order-independent)
- Restore @app.get("/") root handler missing from this branch vs main

Co-authored-by: ZohaibHassan16 <zohaibhassan16@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 18:07:42 +05:30
Mohd Kaif f165679c11 Merge pull request #480 from Sameer6305/fix/provenance-ego-graph
fix(provenance): include upstream ancestors + add direction classific…
2026-04-19 17:50:03 +05:30
17460edca9 docs(changelog): add provenance upstream traversal fix entry (#480, #470)
Co-authored-by: Sameer6305 <sameer6305@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 17:36:58 +05:30
7e815920ac fix(provenance): resolve merge conflicts, fix session API, move schemas
- Resolve all merge conflict markers in provenance.py, app.py, .gitignore
- Revert broken session.get_nodes()/get_edges() to session.graph.nodes/edges
- Keep undirected=True ego_graph fix for upstream ancestor traversal
- Add direction field to ProvenanceEdge (upstream/downstream/lateral)
- Group lineage edges in _render_markdown by direction section
- Move ProvenanceNode/ProvenanceEdge/ProvenanceResponse to schemas.py
- Restore complete router import set in app.py (sparql, vocabulary, etc.)

Co-authored-by: Sameer6305 <sameer6305@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 17:30:03 +05:30
Zohaib Hassnain 7f93eb7104 feat(explorer): calm and structurally declutter graph workspace 2026-04-19 01:13:37 +05:00
Ling Li eec3e8804a fix(providers): add missing verbose_mode assignment in generate_typed 2026-04-19 00:12:25 +08:00
Ling Li 9cb6073568 fix(providers): switch DeepSeekProvider from deepseek SDK to OpenAI client
DeepSeek API is compatible with OpenAI, use the openai SDK instead of
the unmaintained deepseek SDK for better compatibility.
2026-04-18 23:21:49 +08:00
Zohaib Hassnain 073c48882c chore 2 2026-04-17 21:24:45 +05:00
Zohaib Hassnain be86d1b5db chore: remove local benchmark helper 2026-04-17 21:23:51 +05:00
Zohaib Hassnain 6f93f429c4 perf(explorer): add indexed search for large graphs 2026-04-17 21:22:12 +05:00
Mohd KaifandCopilot bc683e7a34 Update semantica/explorer/routes/provenance.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 20:48:57 +05:30
Mohd KaifandCopilot cda5310949 Update semantica/explorer/routes/provenance.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 20:48:45 +05:30
Mohd KaifandCopilot 17f88ca600 Update semantica/explorer/routes/provenance.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 20:47:10 +05:30
Sameer6305 658de23357 fix: resolve merge conflicts with upstream main 2026-04-17 19:56:41 +05:30
Sameer6305 66e8964d22 fix(provenance): include upstream ancestors + add direction classification and markdown grouping 2026-04-17 19:33:12 +05:30
Mohd KaifandClaude Sonnet 4.6 892ff4b4a7 fix(export): fix OWLExporter Turtle invalid syntax and silent data-property omission (#478) (#479)
- Add _ttl_block() helper to accumulate all predicate-object pairs before
  writing, producing a single valid Turtle subject block terminated by one
  period — eliminates the bug where rdfs:subClassOf / domain / range were
  appended after a closed '.' block
- Add missing data_properties loop to _export_owl_turtle so
  owl:DatatypeProperty declarations are no longer silently dropped
- Add _escape_ttl_str() to escape quotes, backslashes, newlines, carriage
  returns, and tabs inside Turtle string literals (rdfs:label, rdfs:comment,
  owl:versionInfo)
- Unify optional-field null checks to consistent x = prop.get(); if x: pattern
- Add 43 tests in tests/export/test_owl_exporter.py covering syntax validity,
  data properties, string escaping, null handling, and header output
- Update CHANGELOG.md with [Unreleased] entry

Closes #478

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:08:59 +05:30
Mohd Kaif a88300d74f Merge pull request #477 from Hawksight-AI/feat/node-distance-semantics-472
feat(explorer): add node distance semantics to PathResponse (#472)
2026-04-16 19:46:42 +05:30
KaifAhmad1 390152c78c feat(explorer): add node distance semantics to PathResponse (#472)
- Extend PathResponse with hop_count (len(path)-1) and distance_band
  ("direct"|"near"|"mid-range"|"distant") as first-class API fields
- Add classify_path_distance() to semantica/utils/helpers.py as the
  single source of truth for hop-count thresholds; both the route and
  the visualizer import from it, eliminating duplicate threshold logic
- Populate hop_count and distance_band in find_path route via
  classify_path_distance(); remove local _classify_distance() copy
- Add highlight_path: list[str] param to KGVisualizer.visualize_network;
  path edges rendered as a distance-aware orange trace (opacity and
  stroke width scale from direct→distant: 1.0/4px to 0.35/1.5px)
- Fix bidirectional edge lookup: only forward pairs (A→B) along the
  path are added to path_edge_set; reverse back-edges in directed
  graphs are no longer incorrectly highlighted
- Add logger.warning when highlight_path contains node IDs absent from
  the layout position map, surfacing silent no-op mismatches
- Extend frontend PathResponse type in GraphInspectorPanel.tsx and
  GraphWorkspaceShell.tsx with hop_count: number and distance_band
  literal union to match the updated API contract
- Add 10 new tests: 2 API-level and 8 unit tests covering all four
  band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops); 104 explorer tests
  pass, 0 failures introduced
- Update CHANGELOG.md
2026-04-16 17:59:50 +05:30
Mohd Kaif 17602812f9 Merge pull request #476 from Hawksight-AI/feat/bidirectional-path-finding-469
feat(explorer): Bidirectional Path Finding in Knowledge Explorer
2026-04-16 15:29:47 +05:30
KaifAhmad1andClaude Sonnet 4.6 523b02083f feat(explorer): add bidirectional path finding with directed=false param (#469)
- PathFinder.bfs_shortest_path() and dijkstra_shortest_path() gain a
  directed: bool = True parameter. When False, a temporary undirected
  view (graph.to_undirected()) is used for traversal only; the original
  directed edges are preserved and returned in the response.
- _make_undirected_view() helper added to PathFinder; falls back safely
  for non-NetworkX graph types.
- GET /api/graph/node/{id}/path exposes ?directed=false query param.
- PathResponse gains a directed: bool field echoing the mode used.
- Route now returns 404 on empty path (previously returned 200 with
  path: []).
- 21 new tests: 12 unit (TestBidirectionalPathFinding) + 9 API-level
  (TestBidirectionalPathRoute). All 120 tests pass.
- CHANGELOG updated under [Unreleased].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:20:12 +05:30
Mohd Kaif 952a4530f5 Merge pull request #474 from Hawksight-AI/kg
feat(kg): Native `KnowledgeGraph` Support in `KGVisualizer`
2026-04-16 12:26:19 +05:30
KaifAhmad1 2ce5067aa3 docs(changelog): add entry for #471 native KnowledgeGraph support in KGVisualizer 2026-04-16 12:18:38 +05:30
KaifAhmad1 d056e47ab7 feat(kg): add KnowledgeGraph dataclass and native KGVisualizer support (#471)
- Add semantica/kg/knowledge_graph.py with KnowledgeGraph dataclass
  (entities, relationships, metadata) plus __len__ and __bool__ helpers
- Export KnowledgeGraph from semantica/kg/__init__.py
- Add KGVisualizer._convert_knowledge_graph() for explicit, non-mutating
  conversion from KnowledgeGraph to internal dict format
- Route isinstance(graph, KnowledgeGraph) through _convert_knowledge_graph
  inside _normalize_graph so all five visualize_* entry points accept
  KnowledgeGraph directly without any manual conversion
- Add TestFormalKnowledgeGraphType (15 tests)

Closes #471
2026-04-16 12:15:59 +05:30
Mohd KaifandClaude Sonnet 4.6 8eafd2d024 fix(explorer): replace KeyError/ValueError with HTTPException across all routes, fix temporal pattern method, add SPA root handler (#463)
- routes/graph.py: raise HTTPException(404) for missing nodes; wrap path_finder call in try/except → HTTPException(404)
- routes/decisions.py: raise HTTPException(404) on chain, compliance, precedents sub-routes
- routes/annotations.py: raise HTTPException(404) on create (missing node) and delete (missing annotation)
- routes/enrich.py: raise HTTPException(404/503/422) for missing nodes, unavailable services, and extraction errors
- routes/temporal.py: fix TemporalPatternDetector method name detect_patterns → detect_temporal_patterns
- explorer/app.py: root / now serves built index.html when available, falls back to minimal HTML shell; add HTMLResponse import
- tests/explorer/test_explorer_api.py: test_extract accepts 503 (spacy/transformers not installed is a valid service state)

All 45 explorer API integration tests pass.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:19:19 +05:30
Mohd Kaif 7ba93f6772 Add initialization file for Claude 2026-04-14 23:25:20 +05:30
Mohd Kaif d466203761 Add initialization file for Claude skills 2026-04-14 23:24:39 +05:30
Mohd Kaif 730dea7911 Add initialization comment to semantica file 2026-04-14 23:24:00 +05:30
Mohd KaifandClaude Sonnet 4.6 47764c3033 Utils Explorer Welcome Message, Version Bump & Plugin README Overhaul (#462)
* Clarify plugin README install and usage steps

* feat(explorer): add welcome message to root endpoint and bump version to 0.4.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plugins): update all plugin READMEs for v0.4.0 with full platform list

- Rewrite main community guide with platform table (8 plugins), skills/agents
  inventory, Knowledge Explorer section, and per-platform install steps
- Add v0.4.0 badge and Knowledge Explorer section to VS Code, Cline,
  Continue, Windsurf, and OpenClaw READMEs
- Fix inconsistent tool count (12 → 17) across all READMEs
- Bump Python requirement from 3.8+ to 3.10+ across all plugins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add PR description for utils → main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove PR_DESCRIPTION.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:32:04 +05:30
Mohd KaifandClaude Sonnet 4.6 055d2fd98d docs: reorganise README integrations and agentic frameworks sections (#461)
- Remove duplicate integrations table from bottom of README
- Move Agentic Frameworks section to top alongside AI tools table
- Show only Agno as supported; list LangChain, LangGraph, CrewAI, LlamaIndex, AutoGen, OpenAI Agents SDK, Google ADK as coming soon
- Add logo icons for all agentic frameworks matching existing plugin style

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:08:18 +05:30
Mohd Kaif ce66681715 Delete RELEASE_NOTES.md 2026-04-14 14:11:59 +05:30
Mohd Kaif 655b553262 Delete STRATEGIES_SUMMARY.md 2026-04-14 14:11:37 +05:30
Mohd KaifandClaude Sonnet 4.6 60bf8ec75e feat(integrations): add OpenClaw plugin and integration module (#460)
- Add integrations/openclaw/ with OpenClawKGTool (REST) and
  OpenClawMCPConfig (mcporter.json generator)
- Add plugins/.openclaw-plugin/ bundle (plugin.json, marketplace.json,
  README) with MCP + native tool support
- Add OpenClaw badge to README header
- Reorganize "Works With Every AI Tool" table into labeled groups:
  Native Plugin Bundle, MCP Server + Plugin, MCP Server, REST API

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:27:25 +05:30
Mohd Kaif a1478af9c4 Merge pull request #453 from Hawksight-AI/explorer
feat(explorer): add Semantica Knowledge Explorer UI with full feature…
2026-04-14 11:43:57 +05:30
Mohd Kaif ee4d6a9188 Update CHANGELOG with recent changes and fixes
Updated CHANGELOG to reflect recent fixes and security enhancements, including improvements to KGVisualizer and vulnerability fixes.
2026-04-14 11:21:27 +05:30
Mohd Kaif 2d00257ae5 Merge pull request #459 from Hawksight-AI/visualization
fix(visualization): Accept KnowledgeGraph objects in all `visualize_*` methods
2026-04-14 11:18:41 +05:30
KaifAhmad1andClaude Sonnet 4.6 e78ad7f819 fix(visualization): accept KnowledgeGraph objects in all visualize_* methods (closes #458)
KGVisualizer.visualize_network() (and sibling methods) only accepted a raw
dict. Passing a KnowledgeGraph object — the natural output of
GraphBuilder.build() — silently returned without rendering.

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

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

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

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:43:04 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 1cc2f6b93a Potential fix for pull request finding 'Wrong number of arguments in a class instantiation'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:52:23 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 9e26d96b3c Potential fix for pull request finding 'Wrong number of arguments in a call'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:46:50 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 7267425eb5 Potential fix for pull request finding 'Wrong number of arguments in a call'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:46:34 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> d9cf7b0088 Potential fix for pull request finding 'Unused global variable'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:46:14 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 09666806da Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:45:55 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 9daddd8186 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:45:40 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> dc8d7ddb03 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:44:44 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> b34634c8b5 Potential fix for pull request finding 'Wrong number of arguments in a call'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:44:28 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> ee93c4bbe1 Potential fix for pull request finding 'Wrong name for an argument in a class instantiation'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:44:13 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> e4425818e4 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:43:56 +05:30
KaifAhmad1andClaude Sonnet 4.6 7b31304e1e feat(mcp): add modular MCP server package at repo root
Adds a fully self-contained `mcp/` package that exposes Semantica as a
Model Context Protocol server over stdio (JSON-RPC 2.0).

17 tools across 5 domains:
- Extraction: extract_entities, extract_relations, extract_all
- Decision intelligence: record_decision, query_decisions, find_precedents,
  get_causal_chain, analyze_decision_impact
- Knowledge graph: add_entity, add_relationship, search_graph,
  get_graph_summary, get_graph_analytics
- Reasoning: run_reasoning, abductive_reasoning
- Export & provenance: export_graph (JSON/CSV/GraphML/Parquet/RDF), get_provenance

4 resources: semantica://graph/summary, semantica://decisions/list,
semantica://schema/info, semantica://ontology/schema

Package layout:
  mcp/__init__.py + __main__.py  — entry points (python -m mcp)
  mcp/server.py                  — SemanticaMCPServer + stdio event loop
  mcp/session.py                 — lazy ContextGraph singleton
  mcp/schemas.py                 — JSON Schema for all 17 tool inputs
  mcp/tools/{extraction,decisions,graph,reasoning,export}.py
  mcp/resources/registry.py      — URI → handler map
  mcp/README.md                  — per-tool setup (Claude Code, Cursor, Windsurf,
                                   Cline, Continue, VS Code, Amazon Q)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:38:23 +05:30
KaifAhmad1andClaude Sonnet 4.6 ab93ec3e8f feat(plugins): add MCP server + 4 new plugin bundles (Windsurf, Cline, Continue, VS Code)
MCP Server (semantica/mcp_server.py):
- Full stdio-based MCP server compatible with Claude Desktop, Windsurf,
  Cline, Continue, VS Code, Roo Code, and any MCP-aware tool
- 12 tools: 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
- Lazy graph session with optional SEMANTICA_KG_PATH env var
- JSON-RPC 2.0 over stdin/stdout; run with: python -m semantica.mcp_server

New plugin bundles (each: plugin.json + marketplace.json + README.md):
- plugins/.windsurf-plugin/ — Windsurf MCP config + 17 skills + 3 agents
- plugins/.cline-plugin/    — Cline MCP config + 17 skills + 3 agents
- plugins/.continue-plugin/ — Continue MCP config + 17 skills + 3 agents
- plugins/.vscode-plugin/   — VS Code MCP config + 17 skills + 3 agents

Updated plugins/.claude-plugin/README.md:
- Platform support table expanded to 9 tools
- Full MCP server section: per-tool config snippets for Claude Desktop,
  Windsurf, Cline, Continue, VS Code; tool/resource reference tables;
  environment variables

Updated README.md:
- Hero line updated to mention MCP server
- Visual grid: Windsurf/VS Code/Cline/Continue → 'MCP server + plugin';
  Claude Desktop → 'MCP server'
- Plugin Bundles section: expanded table listing all 7 bundles with dirs
- New MCP Server section with quick-start snippet and tool/resource list
- Detailed integrations table: corrected connection types and config paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:59:45 +05:30
KaifAhmad1andClaude Sonnet 4.6 f2eb3e1608 docs(readme): accurate plugin/integration/API docs based on actual code
Tools grid:
- Claude Code/Cursor/Codex: 'Native plugin' (plugins/ dirs exist in repo)
- All other tools: 'REST API' (no MCP server impl in codebase — Semantica
  has an MCP CLIENT for ingesting from MCP servers, not an MCP server)
- Codex CLI added back (has real plugin bundle at plugins/.codex-plugin/)

Plugin Bundles section:
- Full table of all 17 skills with descriptions matching SKILL.md files
- Full table of all 3 agents (kg-assistant, decision-advisor, explainability)
- Hooks entry referencing plugins/hooks/hooks.json

MCP Client section:
- Correct framing: MCPClient in semantica/ingest/mcp_client.py pulls
  data FROM MCP servers into KG (not an MCP server itself)
- Code snippet + supported schemes

REST API Server section:
- Lists all 10 route modules from semantica/explorer/routes/ with paths
- WebSocket /ws endpoint
- Health check

Agno integration section:
- Expanded to table showing all 5 actual files in integrations/agno/
  with class names and descriptions matching source code

AI Coding Tools table:
- Corrected connection types and setup notes to match actual code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:51:34 +05:30
KaifAhmad1andClaude Sonnet 4.6 898a660ca7 docs(readme): add explicit integrations table for all 16 AI tools + expand sections
- Add 'AI Coding Tools & IDEs' table under Integrations listing every
  tool from the visual grid with connection type and setup note:
  Claude Code, Cursor, Windsurf, Claude Desktop, VS Code, GitHub
  Copilot, Cline, Roo Code, Continue, Goose, Kilo Code, Aider,
  Amazon Q, Zed, Claude SDK, REST API (109 endpoints)
- Add Neo4j to Graph Databases list (was in modules but missing here)
- Add Email and Repository ingestors to Data Sources
- Expand LLM Providers: add Groq, HuggingFace, Ollama entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:25:02 +05:30
KaifAhmad1andClaude Sonnet 4.6 fb1e6a6d6e docs(readme): revise tools grid with accurate popular integrations
AI tools grid (removed Gemini CLI, Codex CLI; added VS Code, GitHub
Copilot, Continue, Amazon Q, Zed — all confirmed MCP-supporting tools
with significant user bases in 2026):
Row 1: Claude Code, Cursor, Windsurf, Claude Desktop, VS Code,
        GitHub Copilot, Cline, Roo Code
Row 2: Continue, Goose, Kilo Code, Aider, Amazon Q, Zed,
        Claude SDK, Any agent REST API

Agentic frameworks grid (added LangGraph and OpenAI Agents SDK, expanded
to 8 entries): Agno, LangChain, LangGraph, LlamaIndex, AutoGen, CrewAI,
OpenAI Agents SDK, Google ADK

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 15:21:15 +05:30
KaifAhmad1andClaude Sonnet 4.6 0d7b9ca1df docs(readme): add Semantica Knowledge Explorer section to main README
- New '🖥️ Semantica Knowledge Explorer' section placed after Plugins,
  with a workspace-tab table (Graph, Timeline, Decisions, Registry,
  Entity Resolution, KG Overview, Ontology), a 4-line quick-start
  snippet, requirements line, and a pointer to explorer/README.md
- Added explorer/ row to the detailed Modules table with a link
- Added explorer/ bullet to the condensed Modules list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:59:21 +05:30
KaifAhmad1andClaude Sonnet 4.6 aca15d3694 docs(explorer): replace default Vite README with full local setup guide
Covers requirements (Node 18+/Python 3.8+), backend start command,
npm install, dev server, all 6 workspace tabs, available npm scripts,
API/WebSocket proxy table, production build, troubleshooting steps,
and tech stack summary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:30:27 +05:30
KaifAhmad1andClaude Sonnet 4.6 670027fd22 fix(explorer): resolve 3 code-review bugs in GraphWorkspace, DecisionWorkspace, and index.css
- GraphWorkspace: set isRunningPredictions=true before link-prediction fetch
  and false in finally block; pass isRunningPredictions prop to
  LazyGraphInspectorPanel so the inspector button disables and shows a
  spinner during the request (was declared but never wired — broke
  noUnusedLocals TypeScript build)

- DecisionWorkspace: add AbortController to the /api/decisions useEffect
  so the fetch is cancelled on unmount; add per-call AbortController to
  handleSelectDecision for /api/decisions/:id/chain; add res.ok guards
  before .json() on both fetches; encodeURIComponent on decision_id to
  prevent path-injection edge cases

- index.css: add missing @keyframes skeleton-pulse rule (0%/100% opacity
  0.45, 50% opacity 0.85) — KGOverviewTab skeletonBarStyle referenced
  this animation but it was never defined, leaving skeleton bars static

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:16:59 +05:30
KaifAhmad1andClaude Sonnet 4.6 3ea1283626 feat(explorer): add Semantica Knowledge Explorer UI with full feature set
## Folder & Project
- Renamed `semantica-explorer/` → `explorer/` (cleaner path)
- Browser tab title: `Semantica Knowledge Explorer`
- Brand pill: `SEM` → `SKE` (tooltip: Semantica Knowledge Explorer)
- Nav rail label: `Explore` → `Knowledge Explorer`
- package.json name: `semantica-knowledge-explorer`
- Downgraded Vite 8 → Vite 5 for Node v20.17.0 compatibility

## App Shell
- Dynamic per-workspace kicker labels replacing static "Workspace" pill:
  Graph Studio · Vocabulary Browser · Reasoning Engine · SPARQL Query ·
  Decision Intelligence · Knowledge Audit · Graph Governance

## Enrich Workspace — 2 new tabs
### Entity Resolution tab
- Similarity threshold slider (0.50–0.99)
- Run Dedup Scan → POST /api/enrich/dedup
- Flagged pairs list with colour-coded score bars (red/amber/green)
- Expandable inline diff: primary vs duplicate side-by-side
- One-click Merge → POST /api/enrich/merge with logEvent dispatch
- Dismiss per pair; Clear all button
- Merge history sidebar pulled live from Registry store

### Registry tab (Document Registry)
- Live chronological audit log of all KG mutations in-session
- Colour-coded op-type badges: IMPORT · MERGE · ADD NODE · ADD EDGE ·
  INFER · DELETE · EXPORT · VOCAB
- Filter pills to narrow by operation type
- Expandable JSON detail rows per entry
- Clear log button
- Entirely client-side via registryStore (no backend needed)

## Manage Workspace — 2 new tabs
### KG Overview tab
- Stats chips: total nodes, edges, graph density
- Node type breakdown bar chart (up to 8 types, colour-coded)
- Edge type breakdown bar chart from /api/graph/stats
- Top-10 most connected nodes ranked by degree
- Skeleton loading states + Refresh button

### Ontology Summary tab
- Read-only SKOS scheme tree (scheme → top concepts → narrower)
- Concept detail panel: labels, notation, description, narrower nav
- "Open Full Browser" button deep-links to Vocabulary Browser tab

## Decision Workspace polish
- CausalFlowDiagram: vertical node cards connected by relationship pills
- Outcome badges: colour-coded (green=approved, red=rejected, amber=deferred)
- Live filter input across decision ID, category, and outcome
- Animated skeleton loading while list fetches

## Graph Inspector polish
- PathFlowViz: clickable node chips connected by edge-type labels;
  clicking a chip focuses that node in the canvas
- Link Prediction button shows spinner while computing
- Empty states for path trace and candidate links sections

## Registry dispatch — WebSocket
- ADD_NODE events → logEvent("add-node", …) in GraphWorkspace WS handler
- ADD_EDGE events → logEvent("add-edge", …) in GraphWorkspace WS handler
- Import, Export, Merge already dispatched logEvent on API response

## Graph visibility overhaul
### Edge colours (were nearly transparent, now clearly visible)
- edgeBackbone:    rgba(…, 0.04)  → rgba(…, 0.38)
- edgeStructure:   rgba(…, 0.009) → rgba(…, 0.28)
- edgeInspection:  rgba(…, 0.026) → rgba(…, 0.48)
- Muted edges:     0.009–0.02    → 0.12–0.26
- Focus edges:     0.16          → 0.42

### Edge sizes
- default minSize: 0.18 → 0.9 (always at least 1 pixel wide)
- path minSize:    1.8  → 2.4
- inactive/muted:  hide:true → hide:false (dimmed not hidden)

### Node sizes
- default sizeMultiplier: 0.72 → 0.92
- default minSize:        0.68 → 3.5 (visible at all zoom levels)
- overview nodeScale:     0.66 → 0.88
- nodeTintMix (colour):   0.03 → 0.14
- nodeCoreMix (brightness): 0.52 → 0.72

### Label budget
- overview:   10  → 28 labels
- structure:  36  → 60 labels
- inspection: 80  → 120 labels

### Sigma settings
- renderEdgeLabels:        false → true  (relationship type on every edge)
- edgeLabelSize:           —    → 10
- labelRenderedSizeThreshold: 4 → 2
- labelDensity:            0.86 → 1.1
- hideLabelsOnMove:        true → false (labels stay visible while panning)
- hideEdgesOnMove:         true → false (edges stay visible while panning)
- minCameraRatio:          —    → 0.04 (prevents zooming inside a node)
- maxCameraRatio:          —    → 8    (graph stays visible when zoomed out)

### Zoom controls
- Added Zoom In (+) and Zoom Out (−) buttons to graph toolbar
- Smooth animated zoom via camera.animatedZoom / animatedUnzoom (200ms)
- Mouse scroll wheel clamped between minCameraRatio and maxCameraRatio

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Implemented Universal Sizing (logarithmic scale based on node degree) and a Procedural Color Mapper (string hashing) to automatically size and colorize categorical data.
- Built the 'Focus Mode' engine using Sigma reducers. Hovering or clicking a node instantly isolates it and its 1-hop neighbors while muting the canvas, eliminating visual noise.
- Applied an enterprise-grade visual style, featuring deep radial background gradients, structural grid overlays, and a sliding glassmorphism metadata HUD.
- Shifted from DOM-bound state mutations to direct WebGL render pipelines to maintain visual performance.
2026-04-03 00:20:52 +05:00
ZohaibHassan16 9203c2d684 feat(ui): complete phase 2 massive graph rendering and api alignment 2026-04-01 23:51:42 +05:00
ZohaibHassan16 719063e781 Merge branch 'fix/cg-pagination' into feat/explorer-vocab-ui 2026-04-01 12:18:08 +05:00
ZohaibHassan16 60c00fb5c2 feat(explorer): implement phase 1: single-server deployment and dockerization 2026-03-31 13:47:49 +05:00
ZohaibHassan16 1b277dcdd7 feat(ui): wire TanStack query, update UI types, and configure Vite proxy 2026-03-31 04:43:25 +05:00
ZohaibHassan16 3065f3c00e feat(explorer): add initial UI for SKOS Vocabulary Workspace 2026-03-31 02:24:10 +05:00
173 changed files with 35181 additions and 2526 deletions
+1
View File
@@ -0,0 +1 @@
# Initialization
+1
View File
@@ -0,0 +1 @@
# Intialization
+1
View File
@@ -0,0 +1 @@
# Initialization
+11
View File
@@ -0,0 +1,11 @@
name: "Semantica CodeQL Config"
# Exclude auto-generated notebook exports and bundled third-party JS.
# Files in cookbook/**/*.html are self-contained Plotly/MapLibre bundles
# produced by Jupyter nbconvert — they embed minified third-party libraries
# (Plotly, MapLibre GL JS) whose internal patterns trigger false-positive JS
# alerts (js/incomplete-url-substring-sanitization, js/insecure-randomness,
# js/prototype-pollution-utility). These are not application code.
paths-ignore:
- "cookbook/**/*.html"
- "cookbook/**/*.js"
+17 -35
View File
@@ -27,6 +27,7 @@ jobs:
with:
languages: python
queries: security-and-quality
config-file: .github/codeql/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v4
@@ -49,38 +50,19 @@ jobs:
wait-for-processing: true
continue-on-error: true
dismiss-fixed-alerts:
name: Dismiss Fixed Security Alerts
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Dismiss resolved CodeQL alerts via API
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
FIXED_PATTERNS=(
"py/clear-text-logging-sensitive-data"
"py/incomplete-url-substring-sanitization"
"actions/missing-workflow-permissions"
)
# Fetch all open code scanning alerts
ALERTS=$(gh api repos/$REPO/code-scanning/alerts \
--jq '.[] | {number: .number, rule: .rule.id, state: .state}' \
-X GET -f state=open -f per_page=100)
for PATTERN in "${FIXED_PATTERNS[@]}"; do
ALERT_NUMS=$(echo "$ALERTS" | jq -r \
"select(.rule == \"$PATTERN\") | .number")
for NUM in $ALERT_NUMS; do
echo "Dismissing alert #$NUM ($PATTERN) — fixed in security-enhancement PR"
gh api repos/$REPO/code-scanning/alerts/$NUM \
-X PATCH \
-f state=dismissed \
-f dismissed_reason="won't fix" \
-f dismissed_comment="Fixed in PR security-enhancement: code changes remove the vulnerability. Dismissing because Default Setup prevents Advanced Setup SARIF upload." \
&& echo " ✓ Alert #$NUM dismissed" \
|| echo " ⚠ Could not dismiss alert #$NUM (may already be closed)"
done
done
# NOTE: Auto-dismissal by rule-id is intentionally removed.
# Dismissing every alert that matches a rule ID would silently suppress
# future real vulnerabilities of the same type. The alerts below were
# individually triaged and dismissed manually in the security-enhancement
# PR (alerts #12#18). New alerts must be reviewed and dismissed by hand,
# or will auto-close when the underlying code no longer triggers them.
#
# If you need to dismiss a specific known-safe alert, pin its alert NUMBER
# here and remove it once CodeQL stops reporting it naturally. Example:
#
# PINNED_ALERT_NUMBERS=(12 13 14 15 16 17 18)
# for NUM in "${PINNED_ALERT_NUMBERS[@]}"; do
# gh api repos/$REPO/code-scanning/alerts/$NUM \
# -X PATCH -f state=dismissed -f dismissed_reason="false positive" \
# -f dismissed_comment="<reason>"
# done
+1 -1
View File
@@ -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
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
python-version: '3.11'
- run: pip install build
- run: python -m build
- uses: softprops/action-gh-release@v1
- uses: softprops/action-gh-release@v3
with:
files: dist/*
- uses: pypa/gh-action-pypi-publish@release/v1
+1 -1
View File
@@ -106,7 +106,7 @@ jobs:
- name: Comment PR with Security Results
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
+10
View File
@@ -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/
+92
View File
@@ -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 24471 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 (212 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"` (01 hops), `"near"` (23), `"mid-range"` (46), `"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
View File
@@ -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"]
+316 -27
View File
@@ -14,6 +14,7 @@
[![CI](https://github.com/Hawksight-AI/semantica/workflows/CI/badge.svg)](https://github.com/Hawksight-AI/semantica/actions)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white)](https://discord.gg/sV34vps5hH)
[![X](https://img.shields.io/badge/X-Follow%20Semantica-black?logo=x&logoColor=white)](https://x.com/BuildSemantica)
[![OpenClaw](https://img.shields.io/badge/OpenClaw-Plugin-FF3B30?logo=github&logoColor=white)](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
---
-282
View File
@@ -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`.
- **1825% 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.
-105
View File
@@ -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": [
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/Hawksight-AI/semantica/blob/main/cookbook/advanced/14_Datalog_Style_Reasoning.ipynb)\n",
"\n",
"# Datalog-Style Reasoning\n",
"\n",
"End-to-end guide to Semantica's **`DatalogReasoner`** — a native bottom-up semi-naive fixpoint engine — wired together with `GraphBuilder`, `ContextGraph`, `GraphAnalyzer`, `ExplanationGenerator`, and the supporting data-classes (`DatalogFact`, `DatalogRule`, `InferenceResult`, `Rule`).\n",
"\n",
"## What you will build\n",
"\n",
"| Part | Topic | Key classes |\n",
"|------|-------|-------------|\n",
"| 1 | Core API & EDB/IDB concepts | `DatalogReasoner`, `DatalogFact`, `DatalogRule` |\n",
"| 2 | KG → Datalog pipeline | `GraphBuilder`, `GraphAnalyzer`, `DatalogReasoner` |\n",
"| 3 | ContextGraph integration | `ContextGraph`, `DatalogReasoner.load_from_graph()` |\n",
"| 4 | RBAC access-control policy | `GraphBuilder`, `DatalogReasoner`, `ExplanationGenerator` |\n",
"| 5 | Org hierarchy | `ContextGraph`, `DatalogReasoner`, `InferenceResult` |\n",
"| 6 | Engine introspection | `DatalogFact`, `DatalogRule` internal state |\n",
"\n",
"**Related notebooks**\n",
"- [08_Reasoning_and_Inference.ipynb](08_Reasoning_and_Inference.ipynb) — high-level `Reasoner` with IF/THEN syntax\n",
"- [10_Temporal_Knowledge_Graphs.ipynb](10_Temporal_Knowledge_Graphs.ipynb) — temporal reasoning\n",
"\n",
"**Documentation**: [Reasoning API](https://semantica.readthedocs.io/reference/reasoning/) | [KG API](https://semantica.readthedocs.io/reference/kg/) | [Context API](https://semantica.readthedocs.io/reference/context/)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!pip install -qU semantica"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Reasoning ──────────────────────────────────────────────────────────────\n",
"from semantica.reasoning import (\n",
" DatalogReasoner, # native Datalog fixpoint engine\n",
" DatalogFact, # frozen dataclass: predicate + args tuple\n",
" DatalogRule, # dataclass: head + body (list[BodyAtom])\n",
" ExplanationGenerator, # generates NL justifications\n",
" InferenceResult, # result dataclass consumed by ExplanationGenerator\n",
" Rule, # rule dataclass used by ExplanationGenerator\n",
" RuleType, # enum: IMPLICATION | EQUIVALENCE | CONSTRAINT | TRANSFORMATION\n",
")\n",
"\n",
"# ── Knowledge Graph ────────────────────────────────────────────────────────\n",
"from semantica.kg import (\n",
" GraphBuilder, # constructs KG dicts from entity+relationship sources\n",
" GraphAnalyzer, # centrality, communities, connectivity, metrics\n",
")\n",
"\n",
"# ── Context ────────────────────────────────────────────────────────────────\n",
"from semantica.context import ContextGraph # in-memory graph: add_node/add_edge/find_*\n",
"\n",
"print(\"All Semantica classes imported successfully.\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 1 — Core API: EDB Facts, IDB Rules, Fixpoint\n",
"\n",
"### Datalog in 30 seconds\n",
"\n",
"| Term | Meaning | Example |\n",
"|------|---------|--------|\n",
"| EDB (Extensional DB) | Ground facts you assert | `parent(tom, bob)` |\n",
"| IDB (Intensional DB) | Facts derived by rules | `ancestor(tom, ann)` |\n",
"| Rule (Horn clause) | If body → derive head | `ancestor(X,Y) :- parent(X,Y).` |\n",
"| Variable | Uppercase, unified during eval | `X`, `Y`, `Role` |\n",
"| Constant | Lowercase, matches literally | `tom`, `admin` |\n",
"| Fixpoint | Iterate until no new facts appear | `DatalogReasoner.derive_all()` |\n",
"\n",
"### The canonical example — transitive ancestry"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Step 1: create engine ──────────────────────────────────────────────────\n",
"dr = DatalogReasoner()\n",
"\n",
"# ── Step 2: load EDB (ground facts) ───────────────────────────────────────\n",
"# Syntax: predicate(constant1, constant2) — constants must be lowercase\n",
"edb_facts = [\n",
" \"parent(tom, bob)\",\n",
" \"parent(bob, ann)\",\n",
" \"parent(ann, pat)\",\n",
"]\n",
"for f in edb_facts:\n",
" dr.add_fact(f)\n",
"\n",
"print(f\"EDB loaded: {len(dr._all_facts)} ground facts\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Step 3: add IDB rules (Horn clauses) ──────────────────────────────────\n",
"# Syntax: head(Vars) :- body_atom1(Vars), body_atom2(Vars).\n",
"# Variables start with uppercase; trailing '.' is optional\n",
"dr.add_rule(\"ancestor(X, Y) :- parent(X, Y).\")\n",
"dr.add_rule(\"ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).\") # recursive\n",
"\n",
"print(f\"Rules loaded: {len(dr._rules)}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Step 4: fixpoint evaluation ────────────────────────────────────────────\n",
"# derive_all() runs semi-naive bottom-up evaluation until no new facts appear\n",
"all_facts: list[str] = dr.derive_all()\n",
"\n",
"ancestor_strs = sorted(f for f in all_facts if f.startswith(\"ancestor\"))\n",
"print(f\"Derived {len(ancestor_strs)} ancestor facts:\")\n",
"for f in ancestor_strs:\n",
" print(\" \", f)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Step 5: query ──────────────────────────────────────────────────────────\n",
"# Use '?varname' placeholders — query() auto-calls derive_all() if needed\n",
"# Returns: list[dict] e.g. [{\"Y\": \"bob\"}, {\"Y\": \"ann\"}, {\"Y\": \"pat\"}]\n",
"\n",
"descendants = dr.query(\"ancestor(tom, ?Y)\")\n",
"print(\"All descendants of tom:\", sorted(r[\"Y\"] for r in descendants))\n",
"\n",
"ancestors_of_pat = dr.query(\"ancestor(?X, pat)\")\n",
"print(\"All ancestors of pat: \", sorted(r[\"X\"] for r in ancestors_of_pat))\n",
"\n",
"all_pairs = dr.query(\"ancestor(?X, ?Y)\")\n",
"print(f\"\\nAll ancestor pairs ({len(all_pairs)}):\")\n",
"for row in sorted(all_pairs, key=lambda r: (r[\"X\"], r[\"Y\"])):\n",
" print(f\" {row['X']:6s} → {row['Y']}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 2 — GraphBuilder → DatalogReasoner Pipeline\n",
"\n",
"`GraphBuilder` constructs a structured `{\"entities\": [...], \"relationships\": [...]}` dict from your data. We then:\n",
"\n",
"1. Analyse the graph with `GraphAnalyzer` to understand structure.\n",
"2. Feed `kg[\"relationships\"]` into `DatalogReasoner` as EDB facts.\n",
"3. Apply recursive Datalog rules over the KG."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Build a software-dependency KG ────────────────────────────────────────\n",
"entities = [\n",
" {\"id\": \"pythonsdk\", \"name\": \"Python SDK\", \"type\": \"Component\"},\n",
" {\"id\": \"restapi\", \"name\": \"REST API\", \"type\": \"Component\"},\n",
" {\"id\": \"authservice\", \"name\": \"Auth Service\", \"type\": \"Component\"},\n",
" {\"id\": \"database\", \"name\": \"Database\", \"type\": \"Component\"},\n",
" {\"id\": \"dashboard\", \"name\": \"Dashboard\", \"type\": \"Component\"},\n",
" {\"id\": \"analytics\", \"name\": \"Analytics\", \"type\": \"Component\"},\n",
"]\n",
"relationships = [\n",
" {\"source\": \"pythonsdk\", \"target\": \"restapi\", \"type\": \"depends_on\"},\n",
" {\"source\": \"restapi\", \"target\": \"authservice\", \"type\": \"depends_on\"},\n",
" {\"source\": \"authservice\", \"target\": \"database\", \"type\": \"depends_on\"},\n",
" {\"source\": \"dashboard\", \"target\": \"restapi\", \"type\": \"depends_on\"},\n",
" {\"source\": \"dashboard\", \"target\": \"analytics\", \"type\": \"depends_on\"},\n",
" {\"source\": \"analytics\", \"target\": \"database\", \"type\": \"depends_on\"},\n",
"]\n",
"\n",
"# GraphBuilder validates, deduplicates, and packages the data\n",
"builder = GraphBuilder(merge_entities=True, resolve_conflicts=False)\n",
"kg = builder.build([{\"entities\": entities, \"relationships\": relationships}])\n",
"\n",
"print(f\"KG built — entities: {len(kg['entities'])}, relationships: {len(kg['relationships'])}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Analyse the graph structure before reasoning ───────────────────────────\n",
"# GraphAnalyzer provides centrality, communities, connectivity, and metrics\n",
"analyzer = GraphAnalyzer()\n",
"metrics = analyzer.compute_metrics(graph=kg)\n",
"\n",
"print(\"Graph structure:\")\n",
"print(f\" Nodes : {metrics['num_nodes']}\")\n",
"print(f\" Edges : {metrics['num_edges']}\")\n",
"if \"density\" in metrics:\n",
" print(f\" Density : {metrics['density']:.3f}\")\n",
"if \"is_connected\" in metrics:\n",
" print(f\" Connected : {metrics['is_connected']}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Load KG relationships as EDB facts ────────────────────────────────────\n",
"# GraphBuilder output dicts use the same source/target/type shape that\n",
"# DatalogReasoner.add_fact() natively understands\n",
"dr = DatalogReasoner()\n",
"\n",
"for rel in kg[\"relationships\"]:\n",
" dr.add_fact(rel) # dict path: {\"source\": ..., \"target\": ..., \"type\": ...}\n",
"\n",
"print(f\"EDB loaded: {len(dr._all_facts)} dependency facts\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Transitive dependency closure ─────────────────────────────────────────\n",
"# 'depends_on' is the predicate name that add_fact inferred from 'type'\n",
"dr.add_rule(\"transitive_dep(X, Y) :- depends_on(X, Y).\")\n",
"dr.add_rule(\"transitive_dep(X, Y) :- depends_on(X, Z), transitive_dep(Z, Y).\")\n",
"\n",
"dr.derive_all()\n",
"\n",
"# Everything that transitively depends on the database\n",
"db_deps = sorted(r[\"X\"] for r in dr.query(\"transitive_dep(?X, database)\"))\n",
"print(\"Components that transitively depend on Database:\")\n",
"for c in db_deps:\n",
" print(\" \", c)\n",
"\n",
"# What does pythonsdk transitively depend on?\n",
"sdk_chain = sorted(r[\"Y\"] for r in dr.query(\"transitive_dep(pythonsdk, ?Y)\"))\n",
"print(f\"\\nPython SDK full dependency chain: {sdk_chain}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 3 — ContextGraph + `load_from_graph()`\n",
"\n",
"`DatalogReasoner.load_from_graph(graph)` accepts any `ContextGraph` directly: it calls `graph.find_edges()` and `graph.find_nodes()` and converts each result into EDB facts automatically."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Build an in-memory ContextGraph ───────────────────────────────────────\n",
"# ContextGraph.add_node / add_edge are the canonical way to build in-memory KGs\n",
"cg = ContextGraph()\n",
"\n",
"# Nodes\n",
"for person in [\"alice\", \"bob\", \"carol\", \"dave\", \"eve\"]:\n",
" cg.add_node(person, node_type=\"person\", name=person.capitalize())\n",
"\n",
"# Directed \"follows\" edges\n",
"for src, dst in [(\"alice\", \"bob\"), (\"bob\", \"carol\"), (\"carol\", \"dave\"), (\"alice\", \"eve\"), (\"eve\", \"carol\")]:\n",
" cg.add_edge(src, dst, edge_type=\"follows\")\n",
"\n",
"# Verify the graph built correctly\n",
"nodes = cg.find_nodes(node_type=\"person\")\n",
"edges = cg.find_edges(edge_type=\"follows\")\n",
"print(f\"ContextGraph — nodes: {len(nodes)}, edges: {len(edges)}\")\n",
"print(\"Edges:\", [(e.get(\"source\", e.get(\"source_id\")), e.get(\"target\", e.get(\"target_id\"))) for e in edges])"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── load_from_graph() ingests the ContextGraph directly ───────────────────\n",
"dr = DatalogReasoner()\n",
"n_loaded = dr.load_from_graph(cg) # calls cg.find_edges() + cg.find_nodes() internally\n",
"print(f\"Facts loaded from ContextGraph: {n_loaded}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Influence reach via transitive 'follows' ──────────────────────────────\n",
"dr.add_rule(\"influence(X, Y) :- follows(X, Y).\")\n",
"dr.add_rule(\"influence(X, Y) :- follows(X, Z), influence(Z, Y).\")\n",
"\n",
"dr.derive_all()\n",
"\n",
"# Who can alice reach?\n",
"alice_reach = sorted(r[\"Y\"] for r in dr.query(\"influence(alice, ?Y)\"))\n",
"print(f\"Alice's influence reach : {alice_reach}\")\n",
"\n",
"# Who can reach dave?\n",
"reach_dave = sorted(r[\"X\"] for r in dr.query(\"influence(?X, dave)\"))\n",
"print(f\"Who can influence dave : {reach_dave}\")\n",
"\n",
"# Full influence matrix\n",
"all_influence = dr.query(\"influence(?X, ?Y)\")\n",
"print(f\"\\nTotal influence pairs: {len(all_influence)}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 4 — RBAC Access-Control Policy\n",
"\n",
"We model a role-based access-control (RBAC) system:\n",
"\n",
"1. Use `GraphBuilder` to build a structured KG of users, roles, and permissions.\n",
"2. Load it into `DatalogReasoner` for policy inference.\n",
"3. Use `ExplanationGenerator` to produce audit-ready NL justifications."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Build RBAC graph with GraphBuilder ────────────────────────────────────\n",
"rbac_entities = [\n",
" # Users\n",
" {\"id\": \"alice\", \"type\": \"User\", \"name\": \"Alice\"},\n",
" {\"id\": \"bob\", \"type\": \"User\", \"name\": \"Bob\"},\n",
" {\"id\": \"carol\", \"type\": \"User\", \"name\": \"Carol\"},\n",
" {\"id\": \"dave\", \"type\": \"User\", \"name\": \"Dave\"},\n",
" # Roles\n",
" {\"id\": \"admin\", \"type\": \"Role\", \"name\": \"Administrator\"},\n",
" {\"id\": \"editor\", \"type\": \"Role\", \"name\": \"Editor\"},\n",
" {\"id\": \"viewer\", \"type\": \"Role\", \"name\": \"Viewer\"},\n",
" # Permissions\n",
" {\"id\": \"read\", \"type\": \"Permission\"},\n",
" {\"id\": \"write\", \"type\": \"Permission\"},\n",
" {\"id\": \"delete\", \"type\": \"Permission\"},\n",
" {\"id\": \"manage_users\", \"type\": \"Permission\"},\n",
"]\n",
"rbac_relationships = [\n",
" # User → Role assignments\n",
" {\"source\": \"alice\", \"target\": \"admin\", \"type\": \"has_role\"},\n",
" {\"source\": \"bob\", \"target\": \"editor\", \"type\": \"has_role\"},\n",
" {\"source\": \"carol\", \"target\": \"viewer\", \"type\": \"has_role\"},\n",
" {\"source\": \"dave\", \"target\": \"editor\", \"type\": \"has_role\"},\n",
" # Role hierarchy (admin inherits from editor, editor from viewer)\n",
" {\"source\": \"admin\", \"target\": \"editor\", \"type\": \"role_inherits\"},\n",
" {\"source\": \"editor\", \"target\": \"viewer\", \"type\": \"role_inherits\"},\n",
" # Role → Permission grants\n",
" {\"source\": \"viewer\", \"target\": \"read\", \"type\": \"role_has_perm\"},\n",
" {\"source\": \"editor\", \"target\": \"write\", \"type\": \"role_has_perm\"},\n",
" {\"source\": \"admin\", \"target\": \"delete\", \"type\": \"role_has_perm\"},\n",
" {\"source\": \"admin\", \"target\": \"manage_users\", \"type\": \"role_has_perm\"},\n",
"]\n",
"\n",
"builder = GraphBuilder(merge_entities=True, resolve_conflicts=False)\n",
"rbac_kg = builder.build([{\"entities\": rbac_entities, \"relationships\": rbac_relationships}])\n",
"\n",
"print(f\"RBAC KG — entities: {len(rbac_kg['entities'])}, relationships: {len(rbac_kg['relationships'])}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Analyse RBAC graph structure ──────────────────────────────────────────\n",
"analyzer = GraphAnalyzer()\n",
"metrics = analyzer.compute_metrics(graph=rbac_kg)\n",
"centrality = analyzer.calculate_centrality(rbac_kg, centrality_type=\"degree\")\n",
"\n",
"print(f\"RBAC graph — {metrics['num_nodes']} nodes, {metrics['num_edges']} edges\")\n",
"if isinstance(centrality, dict) and \"degree\" in centrality:\n",
" top = sorted(centrality[\"degree\"].items(), key=lambda x: x[1], reverse=True)[:3]\n",
" print(\"Top-3 nodes by degree centrality:\", top)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Load RBAC KG into DatalogReasoner ────────────────────────────────────\n",
"dr = DatalogReasoner()\n",
"\n",
"for rel in rbac_kg[\"relationships\"]:\n",
" dr.add_fact(rel) # {source, target, type} → predicate(source, target)\n",
"\n",
"# ── IDB rules: transitive role hierarchy ─────────────────────────────────\n",
"dr.add_rule(\"effective_role(R, R2) :- role_inherits(R, R2).\")\n",
"dr.add_rule(\"effective_role(R, R2) :- role_inherits(R, Z), effective_role(Z, R2).\")\n",
"\n",
"# ── IDB rules: inherited permissions ─────────────────────────────────────\n",
"dr.add_rule(\"role_can(R, P) :- role_has_perm(R, P).\")\n",
"dr.add_rule(\"role_can(R, P) :- effective_role(R, R2), role_has_perm(R2, P).\")\n",
"\n",
"# ── IDB rules: user effective permissions ────────────────────────────────\n",
"dr.add_rule(\"can(U, P) :- has_role(U, R), role_can(R, P).\")\n",
"\n",
"dr.derive_all()\n",
"\n",
"print(\"User permissions derived via role-hierarchy inference:\")\n",
"for user in [\"alice\", \"bob\", \"carol\", \"dave\"]:\n",
" perms = sorted(r[\"P\"] for r in dr.query(f\"can({user}, ?P)\"))\n",
" print(f\" {user:6s}: {perms}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── ExplanationGenerator — audit-ready NL justification ──────────────────\n",
"# ExplanationGenerator works with InferenceResult objects.\n",
"# We construct one manually to represent a derived Datalog conclusion.\n",
"\n",
"explainer = ExplanationGenerator(detail_level=\"detailed\")\n",
"\n",
"# Build the Rule object that represents the permission derivation chain\n",
"perm_rule = Rule(\n",
" rule_id=\"rbac_perm_chain\",\n",
" name=\"RBAC permission via role hierarchy\",\n",
" conditions=[\"has_role(alice, admin)\", \"effective_role(admin, viewer)\", \"role_has_perm(viewer, read)\"],\n",
" conclusion=\"can(alice, read)\",\n",
" rule_type=RuleType.IMPLICATION,\n",
" confidence=1.0,\n",
")\n",
"\n",
"# Build InferenceResult representing the Datalog conclusion\n",
"result = InferenceResult(\n",
" conclusion=\"can(alice, read)\",\n",
" rule_used=perm_rule,\n",
" premises=[\n",
" \"has_role(alice, admin)\",\n",
" \"role_inherits(admin, editor)\",\n",
" \"role_inherits(editor, viewer)\",\n",
" \"role_has_perm(viewer, read)\",\n",
" ],\n",
" confidence=1.0,\n",
")\n",
"\n",
"# Generate NL explanation\n",
"explanation = explainer.generate_explanation(result)\n",
"print(\"Explanation type :\", explanation.explanation_type)\n",
"print(\"Conclusion :\", explanation.conclusion)\n",
"print(\"Natural language :\", explanation.natural_language)\n",
"print(\"Reasoning steps :\", len(explanation.reasoning_path.steps))"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Inverse queries ───────────────────────────────────────────────────────\n",
"deleters = sorted(r[\"U\"] for r in dr.query(\"can(?U, delete)\"))\n",
"print(\"Who can delete:\", deleters)\n",
"\n",
"writers = sorted(r[\"U\"] for r in dr.query(\"can(?U, write)\"))\n",
"print(\"Who can write: \", writers)\n",
"\n",
"# All (user, permission) pairs — full policy matrix\n",
"all_caps = dr.query(\"can(?U, ?P)\")\n",
"print(f\"\\nTotal (user, permission) pairs: {len(all_caps)}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 5 — Organisation Hierarchy with ContextGraph\n",
"\n",
"We model a company org-chart using `ContextGraph` and derive:\n",
"- `manages(M, E)` — direct and transitive management\n",
"- `skip_level(M, E)` — two hops up the chain\n",
"- `same_team(X, Y)` — shared team membership"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── ContextGraph: org chart ────────────────────────────────────────────────\n",
"org = ContextGraph()\n",
"\n",
"# Add employees as nodes with metadata\n",
"staff = [\n",
" (\"eng1\", \"engineer\", \"backend\"),\n",
" (\"eng2\", \"engineer\", \"backend\"),\n",
" (\"eng3\", \"engineer\", \"frontend\"),\n",
" (\"techlead\", \"lead\", \"engineering\"),\n",
" (\"design1\", \"designer\", \"ux\"),\n",
" (\"design2\", \"designer\", \"ux\"),\n",
" (\"designlead\",\"lead\", \"design\"),\n",
" (\"vpeng\", \"vp\", \"engineering\"),\n",
" (\"cto\", \"executive\", \"leadership\"),\n",
"]\n",
"for emp_id, role, team in staff:\n",
" org.add_node(emp_id, node_type=\"employee\", role=role, team=team)\n",
"\n",
"# Reporting lines\n",
"reports_to = [\n",
" (\"eng1\", \"techlead\"), (\"eng2\", \"techlead\"), (\"eng3\", \"techlead\"),\n",
" (\"techlead\", \"vpeng\"),\n",
" (\"design1\", \"designlead\"), (\"design2\", \"designlead\"),\n",
" (\"designlead\", \"vpeng\"),\n",
" (\"vpeng\", \"cto\"),\n",
"]\n",
"for employee, manager in reports_to:\n",
" org.add_edge(employee, manager, edge_type=\"reports_to\")\n",
"\n",
"# Team membership edges\n",
"teams = [\n",
" (\"eng1\", \"backend\"), (\"eng2\", \"backend\"), (\"eng3\", \"frontend\"),\n",
" (\"design1\", \"ux\"), (\"design2\", \"ux\"),\n",
"]\n",
"for emp, team in teams:\n",
" org.add_edge(emp, team, edge_type=\"in_team\")\n",
" if not org.find_nodes(node_type=\"team\"):\n",
" org.add_node(team, node_type=\"team\")\n",
"\n",
"print(f\"ContextGraph — nodes: {len(org.find_nodes())}, edges: {len(org.find_edges())}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Load org chart into DatalogReasoner ───────────────────────────────────\n",
"dr = DatalogReasoner()\n",
"n = dr.load_from_graph(org) # uses org.find_edges() + org.find_nodes()\n",
"print(f\"Facts loaded via load_from_graph(): {n}\")\n",
"\n",
"# ── IDB rules ─────────────────────────────────────────────────────────────\n",
"# Transitive management chain\n",
"dr.add_rule(\"manages(M, E) :- reports_to(E, M).\")\n",
"dr.add_rule(\"manages(M, E) :- reports_to(E, Z), manages(M, Z).\")\n",
"\n",
"# Skip-level: exactly two reporting hops\n",
"dr.add_rule(\"skip_level(M, E) :- reports_to(E, Z), reports_to(Z, M).\")\n",
"\n",
"# Same team\n",
"dr.add_rule(\"same_team(X, Y) :- in_team(X, T), in_team(Y, T).\")\n",
"\n",
"dr.derive_all()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Query org hierarchy ────────────────────────────────────────────────────\n",
"# Everyone under CTO\n",
"under_cto = sorted(r[\"E\"] for r in dr.query(\"manages(cto, ?E)\"))\n",
"print(f\"CTO manages ({len(under_cto)} people): {under_cto}\")\n",
"\n",
"# VP Eng's direct + indirect reports\n",
"under_vp = sorted(r[\"E\"] for r in dr.query(\"manages(vpeng, ?E)\"))\n",
"print(f\"VP Eng manages : {under_vp}\")\n",
"\n",
"# Skip-level reports to CTO (people two hops below CTO)\n",
"skip = sorted(r[\"E\"] for r in dr.query(\"skip_level(cto, ?E)\"))\n",
"print(f\"CTO skip-level reports : {skip}\")\n",
"\n",
"# eng1's teammates\n",
"mates = [r[\"Y\"] for r in dr.query(\"same_team(eng1, ?Y)\") if r[\"Y\"] != \"eng1\"]\n",
"print(f\"eng1's teammates : {sorted(mates)}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Build an InferenceResult and explain an org query ────────────────────\n",
"explainer = ExplanationGenerator(detail_level=\"verbose\")\n",
"\n",
"mgmt_rule = Rule(\n",
" rule_id=\"transitive_manages\",\n",
" name=\"Transitive management chain\",\n",
" conditions=[\"reports_to(eng1, techlead)\", \"manages(vpeng, techlead)\"],\n",
" conclusion=\"manages(vpeng, eng1)\",\n",
" rule_type=RuleType.IMPLICATION,\n",
" confidence=1.0,\n",
")\n",
"result = InferenceResult(\n",
" conclusion=\"manages(vpeng, eng1)\",\n",
" rule_used=mgmt_rule,\n",
" premises=[\"reports_to(eng1, techlead)\", \"reports_to(techlead, vpeng)\"],\n",
" confidence=1.0,\n",
")\n",
"\n",
"exp = explainer.generate_explanation(result)\n",
"print(exp.natural_language)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 6 — Engine Introspection: DatalogFact & DatalogRule\n",
"\n",
"After reasoning, the engine's internal state is fully accessible via `DatalogFact` and `DatalogRule` data-classes. Use this for auditing, debugging, or downstream export."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Inspect DatalogRule objects ────────────────────────────────────────────\n",
"# dr._rules → List[DatalogRule]\n",
"# DatalogRule.head_predicate, .head_args, .body (body = List[BodyAtom])\n",
"print(\"Rules in engine:\")\n",
"for rule in dr._rules:\n",
" body_str = \", \".join(\n",
" f\"{atom.predicate}({', '.join(atom.args)})\"\n",
" for atom in rule.body\n",
" )\n",
" head_str = f\"{rule.head_predicate}({', '.join(rule.head_args)})\"\n",
" print(f\" {head_str} :- {body_str}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Inspect DatalogFact objects ────────────────────────────────────────────\n",
"# dr._all_facts → Set[DatalogFact] (EDB + IDB combined after derive_all)\n",
"# dr._fact_index → Dict[predicate, Set[DatalogFact]]\n",
"\n",
"from collections import Counter\n",
"\n",
"# Count facts per predicate\n",
"predicate_counts = Counter(f.predicate for f in dr._all_facts)\n",
"print(\"Facts per predicate (EDB + derived IDB):\")\n",
"for pred, count in sorted(predicate_counts.items()):\n",
" print(f\" {pred:20s}: {count}\")\n",
"print(f\"\\n TOTAL: {len(dr._all_facts)}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Separate EDB from IDB ─────────────────────────────────────────────────\n",
"# EDB predicates are the ones we added via add_fact (not derived by rules)\n",
"idb_predicates = {rule.head_predicate for rule in dr._rules}\n",
"edb_predicates = {f.predicate for f in dr._all_facts} - idb_predicates\n",
"\n",
"print(f\"EDB predicates (base facts) : {sorted(edb_predicates)}\")\n",
"print(f\"IDB predicates (derived) : {sorted(idb_predicates)}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Sample DatalogFact structure ──────────────────────────────────────────\n",
"# DatalogFact is a frozen dataclass: predicate: str, args: Tuple[str, ...]\n",
"manages_facts = sorted(dr._fact_index.get(\"manages\", []), key=lambda f: f.args)\n",
"print(f\"First 5 'manages' DatalogFact objects ({len(manages_facts)} total):\")\n",
"for fact in manages_facts[:5]:\n",
" # Access predicate and args directly from the dataclass\n",
" print(f\" DatalogFact(predicate={fact.predicate!r}, args={fact.args})\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── clear() resets the engine completely ─────────────────────────────────\n",
"print(f\"Facts before clear(): {len(dr._all_facts)}\")\n",
"dr.clear()\n",
"print(f\"Facts after clear(): {len(dr._all_facts)}\")\n",
"print(f\"Rules after clear(): {len(dr._rules)}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## API Summary\n",
"\n",
"### DatalogReasoner\n",
"\n",
"| Method | Input | Output | Notes |\n",
"|--------|-------|--------|-------|\n",
"| `add_fact(f)` | `str` or `dict` | `None` | string: `\"pred(a, b)\"` · dict: `{source, target, type}` |\n",
"| `add_rule(s)` | `str` | `None` | Horn clause: `\"head(X) :- body(X, Y).\"` |\n",
"| `derive_all()` | — | `list[str]` | semi-naive fixpoint; idempotent |\n",
"| `query(pat)` | `str` | `list[dict]` | `\"pred(a, ?Y)\"` → `[{\"Y\": ...}]` |\n",
"| `load_from_graph(g)` | `ContextGraph` | `int` | facts loaded count |\n",
"| `clear()` | — | `None` | resets engine |\n",
"\n",
"### Syntax rules\n",
"\n",
"| Item | Rule | Example |\n",
"|------|------|---------|\n",
"| Variable | Starts **uppercase** | `X`, `Role`, `Parent` |\n",
"| Constant | All **lowercase** | `tom`, `admin`, `database` |\n",
"| Query var | Prefix `?` | `?X`, `?Y`, `?Role` |\n",
"| Rule body | `:-` separator, comma between atoms | `head(X) :- a(X, Z), b(Z, Y).` |\n",
"\n",
"### Class map\n",
"\n",
"```\n",
"GraphBuilder.build() → kg dict {entities, relationships}\n",
" ↓ kg[\"relationships\"] → dr.add_fact(rel)\n",
" \n",
"ContextGraph.add_node/add_edge → in-memory graph\n",
" ↓ dr.load_from_graph(cg)\n",
" \n",
"DatalogReasoner.add_rule() → Horn clause rules\n",
"DatalogReasoner.derive_all() → semi-naive fixpoint\n",
"DatalogReasoner.query() → result rows\n",
" ↓ build InferenceResult\n",
" \n",
"ExplanationGenerator → natural language justification\n",
"GraphAnalyzer → graph structure metrics pre/post reasoning\n",
"DatalogFact / DatalogRule → introspect engine state\n",
"```"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.10.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+197
View File
@@ -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).
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Semantica Knowledge Explorer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4665
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

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

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+71
View File
@@ -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; }
}
+7
View File
@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<App />,
)
+2
View File
@@ -0,0 +1,2 @@
export function pairRegistryKey(source: string, target: string): string;
export function curveGroupForPair(source: string, target: string): string;
+7
View File
@@ -0,0 +1,7 @@
export function pairRegistryKey(source, target) {
return JSON.stringify([source, target]);
}
export function curveGroupForPair(source, target) {
return JSON.stringify([source, target]);
}
+178
View File
@@ -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();
}
+77
View File
@@ -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;
}
+16
View File
@@ -0,0 +1,16 @@
declare module 'vis-timeline/standalone' {
export class Timeline {
constructor(container: HTMLElement, items: any, options?: any);
on(event: string, callback: (properties: any) => void): void;
destroy(): void;
}
}
declare module 'vis-data/standalone' {
export class DataSet<T = any> {
constructor(data?: T[], options?: any);
add(data: T | T[]): void;
update(data: T | T[]): void;
remove(id: string | number | (string | number)[]): void;
}
}
+384
View File
@@ -0,0 +1,384 @@
.sem-workspace-frame {
width: 100%;
height: 100%;
min-width: 0;
display: flex;
flex-direction: column;
flex: 1;
gap: 18px;
padding: 20px;
background:
radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 24%),
linear-gradient(180deg, rgba(8, 17, 29, 0.94), rgba(3, 7, 14, 0.98));
}
.sem-workspace-frame > :last-child {
width: 100%;
min-width: 0;
min-height: 0;
flex: 1;
align-self: stretch;
}
.sem-workspace-hero {
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
padding: 20px 22px;
border-radius: 26px;
border: 1px solid var(--panel-border);
background:
linear-gradient(180deg, rgba(8, 18, 33, 0.88), rgba(11, 22, 38, 0.72)),
radial-gradient(circle at top right, rgba(255, 179, 109, 0.08), transparent 28%);
box-shadow: var(--shadow-strong), inset 0 1px 0 rgba(255,255,255,0.04);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.sem-workspace-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-2);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 12px;
}
.sem-workspace-kicker::before {
content: "";
width: 7px;
height: 7px;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent-2), var(--warm));
box-shadow: 0 0 14px rgba(158, 217, 255, 0.45);
}
.sem-workspace-title {
color: var(--text-1);
font-size: 28px;
line-height: 0.98;
letter-spacing: -0.05em;
font-weight: 800;
margin: 0;
}
.sem-workspace-subtitle {
margin-top: 8px;
color: var(--text-2);
font-size: 13px;
line-height: 1.6;
max-width: 58ch;
}
.sem-workspace-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.sem-grid-two {
display: grid;
grid-template-columns: minmax(0, 1.12fr) minmax(320px, 0.88fr);
gap: 20px;
min-height: 0;
flex: 1;
}
.sem-grid-split {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 18px;
min-height: 0;
flex: 1;
}
.sem-surface {
border-radius: 24px;
border: 1px solid var(--panel-border);
background:
linear-gradient(180deg, rgba(9, 18, 32, 0.84), rgba(10, 18, 31, 0.72)),
radial-gradient(circle at top, rgba(103, 182, 255, 0.05), transparent 34%);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255,255,255,0.04);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.sem-surface--subtle {
background: linear-gradient(180deg, rgba(9, 18, 32, 0.7), rgba(9, 16, 28, 0.52));
}
.sem-surface--accent {
border-color: var(--panel-border-strong);
}
.sem-surface-body {
padding: 20px;
}
.sem-surface-body--tight {
padding: 14px;
}
.sem-section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.sem-section-eyebrow {
color: var(--text-3);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.sem-section-title {
color: var(--text-1);
font-size: 18px;
font-weight: 800;
letter-spacing: -0.04em;
margin: 0;
}
.sem-section-copy {
margin-top: 6px;
color: var(--text-2);
font-size: 13px;
line-height: 1.55;
}
.sem-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(103, 182, 255, 0.16);
background: rgba(103, 182, 255, 0.08);
color: #9ed9ff;
font-size: 12px;
font-weight: 700;
}
.sem-chip--warm {
color: #ffce97;
border-color: rgba(255, 179, 109, 0.16);
background: rgba(255, 179, 109, 0.08);
}
.sem-chip--success {
color: #8bf0bf;
border-color: rgba(80, 210, 159, 0.16);
background: rgba(80, 210, 159, 0.08);
}
.sem-command-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
padding: 12px 14px;
border-radius: 20px;
border: 1px solid rgba(132, 197, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
}
.sem-command-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sem-segmented {
display: inline-flex;
gap: 4px;
padding: 4px;
border-radius: 999px;
border: 1px solid rgba(132, 197, 255, 0.12);
background: rgba(8, 16, 28, 0.58);
}
.sem-button,
.sem-button-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 10px 14px;
border-radius: 14px;
cursor: pointer;
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, opacity 180ms ease;
}
.sem-button:hover,
.sem-button-secondary:hover {
transform: translateY(-1px);
}
.sem-button {
color: white;
border: 1px solid rgba(103, 182, 255, 0.2);
background: linear-gradient(180deg, rgba(53, 130, 245, 0.3), rgba(25, 88, 185, 0.18));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
font-weight: 700;
}
.sem-button-secondary {
color: #d8e8fb;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.035);
font-weight: 600;
}
.sem-button:disabled,
.sem-button-secondary:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.sem-segmented button {
min-height: 36px;
padding: 8px 12px;
border-radius: 999px;
}
.sem-segmented button[data-active="true"] {
background: linear-gradient(180deg, rgba(53, 130, 245, 0.34), rgba(25, 88, 185, 0.2));
border-color: rgba(132, 197, 255, 0.2);
}
.sem-input,
.sem-select,
.sem-textarea {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(132, 197, 255, 0.14);
background: rgba(0, 0, 0, 0.22);
color: var(--text-1);
padding: 12px 14px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
}
.sem-input::placeholder,
.sem-textarea::placeholder {
color: var(--text-3);
}
.sem-textarea {
min-height: 160px;
resize: vertical;
}
.sem-inspector {
height: 100%;
overflow: auto;
}
.sem-empty-state,
.sem-loading-state {
height: 100%;
min-height: 240px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 28px;
color: var(--text-2);
}
.sem-empty-state-title,
.sem-loading-state-title {
color: var(--text-1);
font-size: 16px;
font-weight: 700;
margin: 14px 0 6px;
}
.sem-empty-state-copy,
.sem-loading-state-copy {
font-size: 13px;
line-height: 1.6;
max-width: 42ch;
}
.sem-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.sem-list-item {
width: 100%;
text-align: left;
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(132, 197, 255, 0.08);
background: rgba(255, 255, 255, 0.025);
color: var(--text-1);
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
cursor: pointer;
}
.sem-list-item:hover {
transform: translateY(-1px);
border-color: rgba(132, 197, 255, 0.18);
background: rgba(103, 182, 255, 0.08);
}
.sem-list-item[data-active="true"] {
border-color: rgba(132, 197, 255, 0.22);
background: linear-gradient(180deg, rgba(53, 130, 245, 0.18), rgba(25, 88, 185, 0.1));
}
.sem-table {
width: 100%;
border-collapse: collapse;
color: var(--text-1);
}
.sem-table th {
text-align: left;
color: var(--text-2);
font-size: 12px;
font-weight: 700;
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.sem-table td {
padding: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
font-size: 13px;
}
@media (max-width: 1180px) {
.sem-grid-two,
.sem-grid-split {
grid-template-columns: 1fr;
}
.sem-workspace-hero {
flex-direction: column;
align-items: stretch;
}
.sem-workspace-actions {
justify-content: flex-start;
}
}
+170
View File
@@ -0,0 +1,170 @@
import type { CSSProperties, ReactNode } from "react";
type SurfaceTone = "default" | "subtle" | "accent";
type SurfacePadding = "default" | "tight" | "none";
type ChipTone = "default" | "warm" | "success";
function cx(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}
export function WorkspaceFrame({
kicker,
title,
subtitle,
actions,
children,
}: {
kicker?: string;
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="sem-workspace-frame">
<header className="sem-workspace-hero">
<div>
{kicker ? <div className="sem-workspace-kicker">{kicker}</div> : null}
<h2 className="sem-workspace-title">{title}</h2>
{subtitle ? <p className="sem-workspace-subtitle">{subtitle}</p> : null}
</div>
{actions ? <div className="sem-workspace-actions">{actions}</div> : null}
</header>
{children}
</div>
);
}
export function SurfaceCard({
children,
tone = "default",
padding = "default",
className,
style,
}: {
children: ReactNode;
tone?: SurfaceTone;
padding?: SurfacePadding;
className?: string;
style?: CSSProperties;
}) {
return (
<div className={cx("sem-surface", tone !== "default" && `sem-surface--${tone}`, className)} style={style}>
{padding === "none" ? children : <div className={cx("sem-surface-body", padding === "tight" && "sem-surface-body--tight")}>{children}</div>}
</div>
);
}
export function SectionHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow?: string;
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<div className="sem-section-header">
<div>
{eyebrow ? <div className="sem-section-eyebrow">{eyebrow}</div> : null}
<h3 className="sem-section-title">{title}</h3>
{description ? <p className="sem-section-copy">{description}</p> : null}
</div>
{actions ? <div className="sem-command-group">{actions}</div> : null}
</div>
);
}
export function MetricChip({
children,
tone = "default",
}: {
children: ReactNode;
tone?: ChipTone;
}) {
return <span className={cx("sem-chip", tone !== "default" && `sem-chip--${tone}`)}>{children}</span>;
}
export function CommandBar({
left,
right,
}: {
left?: ReactNode;
right?: ReactNode;
}) {
return (
<div className="sem-command-bar">
<div className="sem-command-group">{left}</div>
<div className="sem-command-group">{right}</div>
</div>
);
}
export function InspectorPanel({
children,
open = true,
className,
}: {
children: ReactNode;
open?: boolean;
className?: string;
}) {
return (
<SurfaceCard className={cx("sem-inspector", className)} padding="none" style={{ display: open ? "block" : "none" }}>
{children}
</SurfaceCard>
);
}
export function EmptyState({
title,
description,
icon,
}: {
title: string;
description: string;
icon?: ReactNode;
}) {
return (
<div className="sem-empty-state">
{icon}
<div className="sem-empty-state-title">{title}</div>
<div className="sem-empty-state-copy">{description}</div>
</div>
);
}
export function LoadingState({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="sem-loading-state">
<div className="animate-spin" style={{
width: 24,
height: 24,
borderRadius: "999px",
border: "2px solid rgba(103, 182, 255, 0.18)",
borderTopColor: "rgba(158, 217, 255, 0.92)",
marginBottom: 12,
}} />
<div className="sem-loading-state-title">{title}</div>
<div className="sem-loading-state-copy">{description}</div>
</div>
);
}
export function SegmentedControl({
children,
}: {
children: ReactNode;
}) {
return <div className="sem-segmented">{children}</div>;
}
@@ -0,0 +1,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"]));
});
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+65
View File
@@ -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,
},
},
},
})
+137
View File
@@ -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)
+57
View File
@@ -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"
+253
View File
@@ -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
View File
@@ -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
```
+27
View File
@@ -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"
+5
View File
@@ -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