Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbbe36983b | ||
|
|
877903358a | ||
|
|
070b36902b | ||
|
|
3b9efb7856 | ||
|
|
2a031f0225 | ||
|
|
d04f2b3643 | ||
|
|
2811469071 | ||
|
|
a0b4793590 | ||
|
|
cdeb04b816 | ||
|
|
b7e31d82b0 | ||
|
|
2eba54caac | ||
|
|
9fd1df9c51 | ||
|
|
4517089a7f | ||
|
|
9553e1a176 | ||
|
|
04fcfb61a7 | ||
|
|
5dc6966706 | ||
|
|
bb956b2735 | ||
|
|
e3a3f6010b | ||
|
|
b6373204e2 | ||
|
|
e385c78977 | ||
|
|
7b581d5960 | ||
|
|
07ca93fe5c | ||
|
|
41e430b928 | ||
|
|
438d8bc7af | ||
|
|
82f1f6bd10 | ||
|
|
c86570b996 | ||
|
|
93dda5e435 | ||
|
|
6ffed78fd9 | ||
|
|
f06de0dab2 | ||
|
|
dd016744ce | ||
|
|
379994867d | ||
|
|
7884d71e23 | ||
|
|
ca5f081793 | ||
|
|
6ad1502224 | ||
|
|
b010ba68fa | ||
|
|
7c8dfbd3c0 | ||
|
|
45400c88d3 | ||
|
|
26f6cdf9e5 | ||
|
|
96f88594c2 | ||
|
|
c0d08c46f7 | ||
|
|
5a388d0bcc | ||
|
|
f516aef8fd | ||
|
|
c9a382e676 | ||
|
|
897d950bdc | ||
|
|
dd8fa17db8 | ||
|
|
92801c220e | ||
|
|
738480606c | ||
|
|
f9e0bcf210 | ||
|
|
bb0e9f49e3 | ||
|
|
f95c1612d5 | ||
|
|
8c202a691e | ||
|
|
304b82fbd6 | ||
|
|
8d2dfaa53c | ||
|
|
39aaae778f | ||
|
|
16d628997a | ||
|
|
5e6ad6e87e | ||
|
|
f6198039fa | ||
|
|
983f5301e8 | ||
|
|
5a852169be | ||
|
|
fe6ca7fccb | ||
|
|
66c8431eee | ||
|
|
3e2a0a3f3b | ||
|
|
d22a54353a | ||
|
|
f165679c11 | ||
|
|
17460edca9 | ||
|
|
7e815920ac | ||
|
|
7f93eb7104 | ||
|
|
eec3e8804a | ||
|
|
9cb6073568 | ||
|
|
073c48882c | ||
|
|
be86d1b5db | ||
|
|
6f93f429c4 | ||
|
|
bc683e7a34 | ||
|
|
cda5310949 | ||
|
|
17f88ca600 | ||
|
|
658de23357 | ||
|
|
66e8964d22 | ||
|
|
892ff4b4a7 | ||
|
|
a88300d74f | ||
|
|
390152c78c | ||
|
|
17602812f9 | ||
|
|
523b02083f | ||
|
|
952a4530f5 | ||
|
|
2ce5067aa3 | ||
|
|
d056e47ab7 | ||
|
|
8eafd2d024 | ||
|
|
7ba93f6772 | ||
|
|
d466203761 | ||
|
|
730dea7911 | ||
|
|
47764c3033 | ||
|
|
055d2fd98d | ||
|
|
ce66681715 | ||
|
|
655b553262 | ||
|
|
60bf8ec75e | ||
|
|
a1478af9c4 | ||
|
|
ee4d6a9188 | ||
|
|
2d00257ae5 | ||
|
|
1cc2f6b93a | ||
|
|
9e26d96b3c | ||
|
|
7267425eb5 | ||
|
|
d9cf7b0088 | ||
|
|
09666806da | ||
|
|
9daddd8186 | ||
|
|
dc8d7ddb03 | ||
|
|
b34634c8b5 | ||
|
|
ee93c4bbe1 | ||
|
|
e4425818e4 | ||
|
|
7b31304e1e | ||
|
|
ab93ec3e8f | ||
|
|
f2eb3e1608 | ||
|
|
898a660ca7 | ||
|
|
fb1e6a6d6e | ||
|
|
0d7b9ca1df | ||
|
|
aca15d3694 | ||
|
|
670027fd22 | ||
|
|
3ea1283626 |
@@ -0,0 +1 @@
|
||||
# Initialization
|
||||
@@ -0,0 +1 @@
|
||||
# Intialization
|
||||
@@ -0,0 +1 @@
|
||||
# Initialization
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -111,5 +111,12 @@ sample_data/
|
||||
# Test Results
|
||||
test_results.txt
|
||||
|
||||
# Frontend workspace artifacts
|
||||
semantica-explorer/
|
||||
node_modules/
|
||||
|
||||
# Frontend build artifacts (generated by Vite — do not track in git)
|
||||
semantica/static/
|
||||
|
||||
# Local graph explorer test datasets
|
||||
demo_out/
|
||||
|
||||
@@ -7,6 +7,156 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Fix: Ontology Hub post-review bug fixes and security hardening** (follow-up to #518, closes security advisory #23, by @KaifAhmad1):
|
||||
- **Broken registry filters** — `fetchRegistry` was sending toolbar filter values (`owl`, `skos`, `internal`, `external`) to the backend as the `status` query param, which only accepts `published|draft|external`, causing those filters to return empty lists. Removed the spurious `status` param; all format/kind filtering is now applied client-side via `filteredEntries`, which already had the correct logic.
|
||||
- **Toggle/refresh URI corruption** — `toggle_ontology` and `refresh_ontology` applied `.removesuffix("/toggle")` / `.removesuffix("/refresh")` to the captured path parameter, which would silently corrupt any ontology URI that legitimately ends with those strings. Starlette's route regex (`/{uri:path}/toggle`) already strips the literal suffix via backtracking, so the `removesuffix` calls were removed and the raw `ontology_uri` parameter is used directly.
|
||||
- **SSRF in URL fetch** — `_fetch_url_sync()` accepted arbitrary user-supplied URLs and called `requests.get()` with no validation, enabling server-side request forgery against internal services. Added `_validate_fetch_url()` which rejects non-`http`/`https` schemes and resolves the hostname via `socket.getaddrinfo`, blocking loopback, private, link-local, reserved, and multicast addresses. Applied to all three fetch sites: preview, load, and refresh.
|
||||
- **File upload format misdetected** — the file picker accepted `.xml` and `.json` but `fmtMap` had no entries for those extensions, causing them to default to `turtle`. Added `xml: "xml"` and `json: "json-ld"` mappings. Changed the unknown-extension fallback from `|| "turtle"` to `?? ""` (empty string), and omit the `format` key from the request body when empty so the backend `_detect_format()` runs instead of receiving a forced incorrect value. Also added `.n3` to the accepted extension list and dropzone hint.
|
||||
- **Inconsistent XML hardening** — `_parse_rdf_sync()` called `rdflib.Graph().parse()` directly, bypassing the `defusedxml`-based XXE protection already present in `semantica/explorer/utils/rdf_parser.py`. Now routes through `_safe_parse_rdf()` from that module, applying consistent protection for all RDF/XML parse paths.
|
||||
- **Search scans whole graph** (`GET /api/ontology/search`) — the endpoint fetched up to 999 999 nodes and performed a linear Python substring scan on every request. Replaced with `session.search(q, limit * 6)` which uses the `GraphSearchIndex`; results are then post-filtered by `_SEARCHABLE_TYPES` and `entity_type` before being returned up to the requested limit.
|
||||
- **ReDoS in format detector** (security advisory #23, CodeQL `py/polynomial-redos`, CWE-1333/730/400) — `_detect_format()` used `re.match(r"_:\w+|<[^>]+>\s+<[^>]+>", ...)` to detect N-Triples content. The `<[^>]+>\s+<[^>]+>` alternative was flagged as a polynomial regular expression on uncontrolled data. The URI-subject branch was already unreachable (strings starting with `<` return `"xml"` two lines above), so the entire regex was replaced with two O(1) string operations: `stripped.startswith("_:")` and `" <" in stripped`. `import re` removed as now unused.
|
||||
|
||||
- **Feature: Ontology Hub — Registry, Loader, Entity Search & SKOS Vocabulary Manager** (closes #518, part of #517, by @KaifAhmad1):
|
||||
- Added a sixth workspace, **Ontology Hub** (`ontology-hub`), to the Knowledge Explorer sidebar with a `GitMerge` icon and "Schema Governance" kicker. The workspace shell hosts six tabs — Registry, Editor, Versions, Alignments, Health, and SHACL — with the active tab persisted in the `ontologyTab` URL search parameter via `window.history.replaceState`.
|
||||
- **Registry tab (`OntologyManager`)** — full CRUD interface for loaded ontologies. Lists entries with color-coded status badges (published / draft / external), format badges (Turtle / XML / JSON-LD / N-Triples), per-ontology stats (class count, concept count, property count), source URL link, and enable/disable toggle, refresh, and remove (with confirmation) actions. Toolbar provides a live search input, All / OWL / SKOS / INTERNAL / EXTERNAL filter pills, an Entity Search button, and a "Load Ontology" button. Empty state surfaces a prominent CTA. Action feedback bar auto-hides after 3 seconds.
|
||||
- **Load Ontology modal (`OntologyLoader`)** — three-tab modal overlay for importing ontologies:
|
||||
- *URL Import*: paste any HTTP(S) URL, click "Fetch Preview" to call `POST /api/ontology/preview` (fetches up to 20 MB, parses with rdflib, returns title / namespace / version / license / format / triple count), then "Load Ontology" (`POST /api/ontology/load`). Advanced options toggle exposes format override, custom display name, description, and tags fields.
|
||||
- *File Upload*: drag-and-drop zone (or browse) accepting `.ttl`, `.rdf`, `.owl`, `.nt`, `.jsonld` files; format auto-detected from extension; multipart `POST /api/ontology/load`.
|
||||
- *Create New*: three modes — From Scratch (namespace + name + description + tags), From Data (sample data textarea for schema inference via `OntologyEngine.from_data()`), From Text (free-text textarea for LLM-assisted schema generation via `OntologyEngine.from_text()`); calls `POST /api/ontology/create`.
|
||||
- **Entity Search panel (`OntologySearch`)** — slide-in right panel with debounced 320 ms search across all loaded ontologies via `GET /api/ontology/search`. Type filter pills: All, Class, Property, Individual, Concept, Scheme. Result rows show label, type badge, URI, definition snippet, and source ontology. Selecting a result opens a detail panel that fetches `GET /api/ontology/entity/{uri}` and renders label, URI, definition, superclasses, subclasses, domain, range, instance count, and external URI link. Long lists use a `CollapsibleList` expanding up to 12 items.
|
||||
- **SKOS Vocabulary Manager (`SKOSVocabularyManager`)** — hierarchical SKOS concept browser activated when a SKOS ontology is selected in the registry. Fetches scheme hierarchy from `GET /api/vocabulary/hierarchy`, renders a recursive `ConceptTreeNode` tree with depth-based indentation, expand/collapse, and selection highlight. Client-side `filterConcepts()` matches label, altLabels, and description. Detail panel fetches `GET /api/ontology/skos/concept/{uri}` and displays all SKOS annotation properties (definition, scopeNote, example, historyNote, editorialNote, changeNote) plus broader / narrower / related / exactMatch / closeMatch lists with clickable navigation.
|
||||
- **Backend (`semantica/explorer/routes/ontology.py`)** — 12 FastAPI endpoints under `GET|POST /api/ontology`:
|
||||
- `GET /registry` — returns the in-memory `app.state.ontology_registry` dict as a list, with optional `q` search and `status` filter query params.
|
||||
- `POST /preview` — streams up to 20 MB from a URL via `requests.get` in `asyncio.to_thread`, parses RDF with rdflib (auto-detects format or accepts `format` param), returns `OntologyPreview` metadata.
|
||||
- `POST /load` — URL or multipart file load; stores parsed nodes/edges into the active graph session and registers an `OntologyEntry` in the registry.
|
||||
- `POST /create` — creates an ontology from scratch, sample data, or natural-language text; falls back to a minimal ontology shell if `OntologyEngine` is unavailable.
|
||||
- `GET /search` — full-text entity search with optional `type` filter across all nodes whose `node_type` maps to class, property, individual, concept, or scheme.
|
||||
- `GET /entity/{uri:path}` — entity detail: label, type, definition, superclasses, subclasses, domain, range, instance count.
|
||||
- `GET /skos/schemes` — lists all `skos:ConceptScheme` nodes in the active session.
|
||||
- `GET /skos/concept/{uri:path}` — full SKOS concept detail including all annotation properties and relation sets.
|
||||
- `DELETE /{uri:path}`, `PATCH /{uri:path}/toggle`, `POST /{uri:path}/refresh` — remove, enable/disable toggle, and re-fetch/re-parse for registered ontologies. Route ordering places all literal paths before the `:path` wildcards to avoid shadowing.
|
||||
- Helper internals: `_parse_rdf_sync()` (rdflib parse → nodes/edges/metadata), `_fetch_url_sync()` (streaming requests with 20 MB cap), `_classify_node_type()` (maps raw RDF types to canonical categories), `_uri_to_prefix()` (URI → prefixed form for display).
|
||||
- Editor, Versions (Subissue 2) and Alignments, Health, SHACL (Subissue 3) tabs render descriptive stub cards with amber subissue badges as placeholders for upcoming implementations.
|
||||
- TypeScript compiled with zero errors; Vite dev server starts cleanly with the new workspace lazy-loaded via `React.lazy` + `Suspense`.
|
||||
|
||||
- **Feature: Explorer landing page redesign** (PR #516 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- Replaced the plain welcome screen with a full landing composition: premium hero section, product preview mock with animated SVG graph, live graph status metrics, intelligence capability band, and consolidated workspace launcher.
|
||||
- `WelcomeScreen` fetches `/api/graph/stats` on mount with `AbortController` cleanup and displays live node and edge counts; falls back to `"Live"` / `"Ready"` labels when the endpoint is unavailable.
|
||||
- Workspace launcher surfaces Network Explorer as the primary path and provides direct one-click entry into Vocabulary, Analyze, Decisions, Enrich, and Manage workspaces.
|
||||
- Added `LandingMetric`, `LandingAction`, and `GraphStatsPayload` TypeScript types; `getNumberStat` handles three API key shapes (`node_count`, `nodeCount`, `nodes` and equivalents) for forward-compatibility.
|
||||
- Added `Space Grotesk` and `IBM Plex Sans` fonts (replacing `Inter`); `JetBrains Mono` used for kickers, badges, and metadata labels.
|
||||
- Added `prefers-reduced-motion` media query suppressing `landing-float` animation and launcher hover transitions.
|
||||
- **Review fixes** (follow-up by @KaifAhmad1 and @ZohaibHassan16): replaced invalid `inset-left` CSS property with `inset: 0 0 0 72px` on `.landing-page::before` in the `≤680px` breakpoint; added the same `inset` correction to `.landing-page::after` which was still offset at `88px` after rail narrowing; merged duplicate `.landing-capability-band` CSS rule blocks into one; corrected non-standard `font-weight: 850` to `800` on `.landing-launcher-item-title`; removed unused `eyebrow` field from `LandingAction` type and all data entries; extracted the static 42-dot SVG background array to a module-level `PREVIEW_DOTS` constant to avoid recomputing it on every render.
|
||||
- **Fix: Semantic Distance UI slash-safe node IDs** (issue #514, PR #515 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- **Root cause** — FastAPI decodes `%2F` before route matching, so node IDs containing `/` (e.g. `gene/protein:6164`) split the path-segment route and return 404. The frontend encoded slashes correctly but they were decoded server-side before the router matched the pattern.
|
||||
- Added slash-safe query-param routes: `GET /api/graph/semantic-neighborhood?node_id=...` and `GET /api/graph/path?source=...&target=...`. Legacy path-segment routes (`/node/{id}/semantic-neighborhood`, `/node/{id}/path`) are kept as deprecated backward-compatible aliases with docstrings documenting the limitation.
|
||||
- Frontend (`GraphWorkspace.tsx`, `GraphWorkspaceShell.tsx`) now builds all distance API calls via `URLSearchParams` so node IDs with slashes or other special characters are never embedded in URL path segments.
|
||||
- `_semantic_neighborhood_impl` now returns HTTP 503 (instead of a silent 200 with zero neighbors) when semantic similarity is unavailable or the graph has no node embeddings, and HTTP 404 only when the anchor node itself does not exist. Frontend error messages updated to distinguish the two cases.
|
||||
- Fixed a pre-existing bug where `find_most_similar` was called with `(graph_dict, node_id_string)` instead of the correct `(embeddings_dict, query_vector)` signature; added `_extract_node_embeddings` and `_coerce_embedding_vector` helpers to build the embeddings dict before the call.
|
||||
- **Review fixes** (follow-up by @KaifAhmad1 and @ZohaibHassan16): aligned `_coerce_embedding_vector` inner dict-probe key list (added `"embeddings"`, reordered generic-first) with `_extract_node_embeddings` outer key list; added `TODO` comment on per-session embedding cache; extracted `_FakeSimilarity` test stub to module level to eliminate duplication; rewrote `test_legacy_semantic_neighborhood_still_works_for_simple_ids` as a fully isolated `TestClient` session instead of mutating the shared module-scoped `client` fixture.
|
||||
|
||||
- **Fix: Explorer Distance Intelligence visible rendering** (PR #513 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- Distance Intelligence now renders as a first-class visual state through the Sigma reducer/theme pipeline instead of mutating raw graph attributes directly.
|
||||
- Ego mode fades and scales nodes by structural distance from the selected anchor; nodes outside `maxHops` are dimmed and label-suppressed.
|
||||
- Heatmap mode renders a sampled local lens capped per ring (1-hop: ≤120, 2-hop: ≤650, 3-hop: ≤900 nodes shown); true counts remain visible in the status strip. Saturation detection reduces alpha for dense outer rings automatically.
|
||||
- Structural mode highlights distance-aware context edges (colored by hop band) without breaking existing edge LOD.
|
||||
- Semantic mode surfaces loading, unavailable, and error states visibly; edges colored by cosine similarity score.
|
||||
- Trace Path inspector shows a distance band chip, hop count, and optional metric cards (confidence decay, semantic similarity, path coherence, bottleneck node) when path data is available.
|
||||
- Added `GraphDistanceVisualState`, `GraphDistanceBucketCounts`, and `GraphHeatmapRenderSnapshot` types in `types.ts`; distance state flows through `GraphCanvas` → `buildReducerSceneState` → Sigma node/edge reducers.
|
||||
- Added `buildStructuralDistanceSnapshot` (bounded BFS), `summarizeDistanceBuckets`, `buildHeatmapRenderSnapshot` (ring-capped deterministic sampling via `hashString` tiebreaker), `resolveDistanceNodeStyle`, and `resolveDistanceEdgeStyle` in `graphSceneState.ts`.
|
||||
- Distance Intelligence status strip shows active mode, anchor label, per-ring node counts, sampled status, and a color legend.
|
||||
- **Review blockers fixed** (follow-up by @KaifAhmad1 and @ZohaibHassan16): removed dead `if (anchorNodeId)` conditional in `buildHeatmapRenderSnapshot` (anchor always truthy past early-return guard); replaced O(n) `.includes()` call in the Sigma reducer hot path with a `WeakMap`-cached `Set.has()` lookup; renamed `GraphDistanceBucketCounts.threeHop → threeHopPlus` so the field accurately reflects ≥ 3 hops and updated status strip labels to "3+ hop"; restored `hasMetrics` guard in `PathDistanceIntelPanel` to suppress the empty metric grid `<div>` when a path result carries no optional metric fields.
|
||||
|
||||
- **Feature: Graph Explorer visual refresh** (PR #503 by @ZohaibHassan16, conflict resolution by @KaifAhmad1):
|
||||
- Extracted all hardcoded `rgba(...)` color literals into a structured `ui.*` design-token namespace in `graphTheme.ts` — covering `ui.text`, `ui.surface`, `ui.scene`, `ui.control`, `ui.timeline`, and `ui.interaction`. Future theming is now a one-file change.
|
||||
- Added `GraphEntityShapeVariant` type and per-shape config (`fillAlpha`, `shellAlpha`, `coreScale`, `borderBoost`, `minSize`) for biomolecule, condition, compound, process, community, and entity node kinds. Shell and fill colors now derive from per-entity-shape config rather than uniform overrides.
|
||||
- Decomposed the monolithic `coreToolbarGroups` useMemo into focused per-cluster memos (`viewModeItems`, `cameraToolbarItems`, `layoutToolbarItems`, `localToolbarItems`, `analysisToolbarItems`, `utilityToolbarItems`) each with minimal deps arrays. Distance Intelligence controls (ego mode, heatmap, structural/semantic overlay) ported into a new `distanceToolbarItems` cluster, gated on node selection.
|
||||
- Replaced the raw `<input>` search bar and inline `<button>` loop with typed sub-components: `SearchCommandBar`, `SegmentedModeControl`, `ToolbarCluster`, `ToolbarButton`, and `EntityVisualKey`. All carry `aria-label`, `role`, and `disabled` attributes.
|
||||
- Added `GraphFullEdgeClass` union (`hidden | backbone | bridge | local-context | selected | path | muted`), `classifyFullGraphEdge`, and `resolveEdgeVisibilityPolicy` for deterministic per-mode LOD edge classification. Full-graph mode visibility and context caps are now declared as data (`edges.visibility`, `edges.contextCaps`) per view mode × zoom tier.
|
||||
- Added `GraphRuntimeDiagnosticsSnapshot` type; `onDiagnosticsChange` callback now emits `{ effectAvailability, edgeClasses, structureLayer }` instead of the internal `effectAvailability` sub-object.
|
||||
- Edge visual state weights (size multipliers, min sizes) tuned for quieter large-graph rendering: default edges dropped from `0.96×` to `0.48×`; muted edges from `0.6×` to `0.24×`; path/selected edges raised slightly to maintain hierarchy contrast.
|
||||
- Scene grid updated to a two-frequency pattern (minor 48 px, major 240 px) with tokens sourced from `GRAPH_THEME.ui.scene`.
|
||||
- `focusNode` early-return now clears `selectedNodeId`, `selectedEdgeId`, `pathResult`, `searchResults`, and `searchError` when called with an empty string.
|
||||
- Added 15 new display-state and edge-classification tests in `explorer/tests/graphSceneState.display.test.ts`.
|
||||
|
||||
- **Feature: Distance Intelligence** (closes #502 by @KaifAhmad1):
|
||||
- **Context layer** — `ContextGraph.get_neighbors()` gains `include_distance_metadata=False`; when enabled adds `distance_band`, `confidence_decay`, and `path_to_anchor` per result. New `get_neighbor_distances()` returns neighbors sorted by `(hop, -decay)` with optional `min_confidence` filter. `AgentContext.retrieve()` / `find_precedents()` accept `anchor_node`, `max_hops`, `proximity_weight`, `min_confidence_decay` and blend graph proximity with semantic score as `combined_score = (1 − w) × semantic + w × proximity`.
|
||||
- **Path enrichment (FR-4)** — `GET /api/graph/node/{id}/path` now returns `semantic_similarity`, `path_coherence_score`, `confidence_decay` (O(L) via pre-built edge-weight index), `bottleneck_node`, `alternative_path_count`, and `interpretation`. All fields optional; zero breaking changes.
|
||||
- **Distance matrix (FR-6)** — `POST /api/graph/distance-matrix` accepts up to 50 nodes and metric `hops | weighted | semantic`. Returns N × N matrix (upper-triangle computed, lower mirrored), unreachable pairs, and `computation_time_ms`.
|
||||
- **Semantic neighborhood (FR-3 backend)** — `GET /api/graph/node/{id}/semantic-neighborhood?top_k=N` returns the N most similar nodes with `id`, `type`, `content`, `similarity`, `hop_distance`.
|
||||
- **Causal distance (FR-8)** — `GET /api/decisions/causal-distance?source=&target=` traverses only causal-typed edges and returns `CausalDistanceReport` with path, hop count, `confidence_decay`, `weakest_link`, and interpretation.
|
||||
- **Temporal distance history (FR-9)** — `GET /api/temporal/distance-history` samples 11 evenly-spaced snapshots across the graph's time range and emits `convergence | divergence | disconnected | reconnected` events.
|
||||
- **Distance-enriched export (FR-10)** — `POST /api/export/distance-enriched` streams pairwise hop/weighted/semantic/band/centrality metrics as CSV or JSONL. `node_subset` capped at 200 nodes.
|
||||
- **Explorer UI** — Path inspector panel (`GraphInspectorPanel.tsx`) shows a distance band chip, progress-bar metric cards (decay, similarity, coherence), bottleneck node highlight, and interpretation text. Toolbar gains Ego Mode (client-side BFS depth-of-field fading, depth slider 1–8), Structural overlay (edges colored by hop distance), Semantic overlay (edges colored by cosine similarity), and Heatmap (nodes colored green → red by hop distance). Ego and heatmap share a single merged `useEffect` to prevent `restoreNodeColors()` races.
|
||||
- **Tests** — 57 new tests in `tests/context/test_distance_intelligence.py`; 18 targeted regression tests in `tests/_smoke_review_fixes.py`.
|
||||
|
||||
- **Fix: Distance Intelligence — code review regressions** (PR #502 follow-up by @KaifAhmad1):
|
||||
- `GraphWorkspace.tsx` semantic fetch used `?limit=50`; corrected to `?top_k=50` to match the backend param (bug_001). Response type widened to full `SemanticNeighborhoodResponse` shape (bug_002).
|
||||
- `ContextGraph.get_neighbors()` was embedding distance metadata unconditionally, breaking existing callers; gated behind `include_distance_metadata=False` default (bug_003).
|
||||
- `weakest_link` dict key standardised from `weight` → `edge_weight` across `CausalChainAnalyzer` and `CausalDistanceReport` (bug_004).
|
||||
- Temporal distance history sampling replaced `timetuple()[:6]` reconstruction with `min_bound + timedelta(seconds=...)` (bug_005).
|
||||
- Confidence decay in `find_path` was O(E × L); replaced with a single O(E) edge-weight index built before the hop loop, with undirected mirroring (bug_006).
|
||||
- `AgentContext._apply_proximity_metadata()` was overwriting the original record `"id"` with the graph node id; stored as `"graph_node_id"` instead (bug_007).
|
||||
- Path highlight sweep animation used a shared `sweepTimer`; stale callbacks fired after cancellation. Added `sweepGeneration` counter — callbacks no-op if generation no longer matches (bug_008).
|
||||
- `POST /api/export/distance-enriched` now rejects `node_subset` larger than 200 nodes with HTTP 413 (sec_001).
|
||||
- `POST /api/graph/distance-matrix` now computes only the upper triangle and mirrors results, halving computation cost (sec_002).
|
||||
- Ego mode and heatmap `useEffect` hooks merged into one to eliminate concurrent `restoreNodeColors()` race (qual_001).
|
||||
- Bare `except Exception: pass` blocks in `find_path` and `semantic_neighborhood` replaced with `logger.debug(...)` (qual_002).
|
||||
- Duplicated `_distance_band()` static method removed from `CausalChainAnalyzer` and `AgentContext`; both now use `classify_path_distance` from `semantica.utils.helpers` (qual_003).
|
||||
|
||||
- **Feature: Graph Workspace declutter + calmer structural exploration** (PR #483 by @ZohaibHassan16, follow-up by @KaifAhmad1):
|
||||
- Added a calmer default presentation for dense graphs: reduced label pressure, stronger inactive-state muting, and tuned zoom-tier visibility to improve readability during overview and structure navigation.
|
||||
- Added display-edge aggregation with raw-edge bundle metadata retention, enabling cleaner visuals while preserving drill-down context for selected edges.
|
||||
- Added grouped community view and neighborhood collapse/expand controls for high-degree local structures in Graph Workspace and Neighborhood panel flows.
|
||||
- Extended graph selection/runtime state with display-state metadata (`groupedViewAvailable`, visible/collapsed neighbor counts, aggregated edge descriptors) for plugin and panel introspection.
|
||||
- Added regression coverage for `resolveDisplayGraph` behavior in `explorer/tests/graphSceneState.display.test.ts`:
|
||||
- parallel-edge aggregation in full view
|
||||
- collapse behavior preserving active-path neighbors
|
||||
- grouped community-node/community-edge projection behavior
|
||||
- Follow-up merge resolution synced the PR branch with `main` after Explorer path migration (`semantica-explorer` -> `explorer`) and preserved PR #483 behavior in conflicted Graph Workspace files.
|
||||
|
||||
- **Fix: DeepSeekProvider now uses OpenAI SDK instead of unmaintained deepseek SDK** (closes #482, PR #482 by @liling, review fixes by @KaifAhmad1):
|
||||
- **Root cause**: The `deepseek` PyPI package has no `deepseek.Client`, causing `AttributeError` on every `DeepSeekProvider` instantiation. The DeepSeek API is OpenAI-compatible, so the `openai` SDK is the correct client.
|
||||
- **`_init_client` rewritten**: Replaced `import deepseek; deepseek.Client(api_key=...)` with `from openai import OpenAI; OpenAI(api_key=..., base_url=self.base_url)`, matching the pattern already used by `NovitaProvider`.
|
||||
- **`self.base_url` added to `__init__`**: Set to `"https://api.deepseek.com/v1"` (with `/v1` suffix required by the OpenAI SDK for correct endpoint resolution). This was missing from the original PR, causing a second `AttributeError` at `_init_client` call time.
|
||||
- **`generate_typed` `verbose_mode` fix**: `verbose_mode` was referenced before assignment inside the instructor path. Added assignment `verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)` at the correct scope.
|
||||
- **`pyproject.toml` updated**: `llm-deepseek` extra now declares `openai>=1.0.0` instead of the defunct `deepseek>=0.1.0`.
|
||||
- **Warning message updated**: `_init_client` ImportError warning now references the `openai` library and `llm-openai` extra.
|
||||
- **Instructor path improved**: Since `self.client` is now an `OpenAI` instance, the `isinstance(self.client, OpenAI)` check in `generate_typed` passes correctly, avoiding a redundant second client construction.
|
||||
- 19 new tests in `tests/semantic_extract/test_pr482_deepseek_openai.py` across five suites: `TestDeepSeekProviderInit` (8 — covers `base_url`, OpenAI instantiation, no `deepseek` import, ImportError handling, `is_available`), `TestDeepSeekProviderGenerate` (5 — `generate`, `generate_structured`, no-client error paths), `TestDeepSeekInstructorPath` (1 — `isinstance` check), `TestVerboseModeAssignment` (4 — no NameError, verbose kwarg, config verbose, no-print default), `TestDeepSeekGenerateTypedInstructorIntegration` (1 — end-to-end instructor path reuses existing client).
|
||||
|
||||
- **Performance: Indexed search for large knowledge graphs** (closes #467, PR #481 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- **Root cause**: The previous `GraphSession.search()` ran a full O(n) scan over all nodes per query, serializing every node's properties to JSON for string matching. On a 118 k-node graph warm queries took 24–471 ms; a session with 500 k nodes was effectively unusable.
|
||||
- **New `semantica/explorer/search_index.py`**: Purpose-built in-memory inverted index with three lookup tiers — exact-term index (full normalized strings), token index (individual words), and prefix index (2–12 character prefixes of every token). A linear secondary-scan fallback (capped at 12 k nodes) handles queries that miss all three tiers. An LRU result cache (128 slots, `OrderedDict`) serves repeat queries at zero cost. Warm query times on the same 118 k-node graph: 24 ms → 0.004 ms (exact), 471 ms → 0.009 ms (ID lookup), 475 ms → 0.002 ms (no-match).
|
||||
- **`IndexedNodeDocument`** frozen dataclass stores per-node primary text (ID, content, curated alias keys: `label`, `name`, `pref_label`, `aliases`, `synonyms`, `display_name`, etc.), secondary text (remaining properties), token set, prefix expansions, confidence, and tags. Primary text prioritizes human-readable fields; secondary text covers the full property bag up to a 48-fragment cap.
|
||||
- **Scoring**: exact ID match → 140, exact term → 120, primary-text substring → 78 + length bonus, token hit → 18, prefix hit → 10, multi-token bonus → 4 per hit. Ties broken deterministically on `(score, exactness, token_hits, node_id)`.
|
||||
- **Mutation sync**: `GraphSession.add_node()`, `add_nodes()`, `add_edges()` and `add_node()` update the index incrementally. `handle_graph_mutation()` integrates with the WebSocket mutation bridge for live updates during reasoning, enrichment, and remote graph changes. `rebuild_search_index()` performs a full O(n) rebuild when needed (session init, merge, reload). `enrich.py` routes node/edge additions through `session.add_node`/`session.add_edge` so reasoning-inferred nodes are indexed immediately.
|
||||
- **Review fixes applied**: replaced `list.sort()` per upsert with `bisect.insort()` (O(log n) vs O(n log n)); replaced `list.remove()` with `bisect.bisect_left` + `pop()` (O(log n) vs O(n)); added `with self._lock` in `handle_graph_mutation()` to prevent index races from the WebSocket thread; removed unnecessary source/target upserts on `add_edge()` (edges don't affect node text); sorted tag values in `_cache_key()` so `["a","b"]` and `["b","a"]` share a cache entry.
|
||||
- **Follow-up fix by @KaifAhmad1 and @ZohaibHassan16**: restored `_ordered_node_ids` maintenance during upserts so `secondary_scan` fallback works for terms that are only present in non-curated properties; added regression test coverage to lock this behavior.
|
||||
- 3 new tests: `test_search_exact_and_prefix` (exact match + prefix match), `test_search_filters_and_cache_stability` (type + confidence filter, identical repeated requests), `test_search_sees_new_nodes_after_mutation` (node added via `session.add_node()` immediately visible in search).
|
||||
- 1 additional regression test: `test_search_secondary_scan_fallback_matches_non_curated_properties` (verifies fallback matching when a query term appears only in non-curated properties).
|
||||
- **Fix: Provenance traversal now includes multi-hop upstream ancestors + edge direction classification** (closes #470, PR #480 by @Sameer6305, review fixes by @KaifAhmad1):
|
||||
- **Bug — upstream ancestors silently excluded**: `_build_provenance()` built a directed `nx.DiGraph` and seeded first-hop neighbors correctly, but the final subgraph extraction called `nx.ego_graph(..., undirected=False)`. With directed traversal, ego-graph expansion only follows outgoing edges from the focus node, so any node that *points into* the focus node (i.e. an upstream ancestor at depth ≥ 2) was invisible. For the chain `Source → Intermediate → node_id`, `Intermediate` appeared at hop 1 but `Source` was silently dropped. Fixed by changing to `undirected=True` — the radius expansion now traverses both incoming and outgoing edges while the underlying `DiGraph` is preserved, so edge source/target semantics remain correct.
|
||||
- **Enhancement — edge direction classification**: `ProvenanceEdge` gains a `direction: str` field. Each edge in the provenance subgraph is classified relative to the focus node: `"upstream"` when `target == node_id` (edge flows into the focus node), `"downstream"` when `source == node_id` (edge flows out), and `"lateral"` for all other edges between non-focus neighbors. This lets consumers distinguish ancestor provenance from descendant impact without re-traversing the graph.
|
||||
- **Enhancement — grouped markdown report**: `_render_markdown()` now groups lineage edges under separate `## Upstream`, `## Downstream`, and `## Lateral` sections instead of a flat `## Lineage Edges` list. Empty sections are omitted. This improves readability of exported provenance reports.
|
||||
- **Schema consolidation**: `ProvenanceNode`, `ProvenanceEdge`, and `ProvenanceResponse` moved from inline definitions in `routes/provenance.py` to the shared `semantica/explorer/schemas.py`, matching the convention used by all other Explorer routes. `ProvenanceNode.parent_id` is now `Optional[str] = None`.
|
||||
- **Merge conflicts resolved**: Resolved all conflict markers in `provenance.py`, `app.py`, and `.gitignore`; restored the complete router import set in `app.py` (`graph`, `sparql`, `temporal`, `vocabulary`) that the conflict had dropped.
|
||||
- 2 new tests in `tests/explorer/test_provenance_route.py`: `test_build_provenance_direction_classification_chain` (asserts `Source` and `Intermediate` both appear for `Source → Intermediate → node_id`; verifies `Intermediate → node_id` classified as `"upstream"`) and `test_render_markdown_groups_edges_by_direction` (asserts grouped section headings and correct edge lines in output).
|
||||
|
||||
- **Fix: `OWLExporter._export_owl_turtle` invalid Turtle syntax and silent data-property omission** (closes #478 by @KaifAhmad1):
|
||||
- **Bug 1 — Invalid Turtle syntax**: `_export_owl_turtle` unconditionally wrote `rdfs:label` with a closing period (`.`), then appended `rdfs:subClassOf`, `rdfs:domain`, and `rdfs:range` triples after the closed block. Any RDF parser would reject the output. Fixed by introducing `_ttl_block(subject_uri, rdf_type, predicates)` — all predicate-object pairs for a subject are accumulated first, then joined with ` ;\n ` and terminated with a single ` .`, producing valid Turtle in all cases.
|
||||
- **Bug 2 — Data properties silently dropped**: `_export_owl_turtle` had loops for `classes` and `object_properties` but no loop for `data_properties`, so all `owl:DatatypeProperty` declarations were silently omitted. Added the missing loop, mirroring the existing object-property loop.
|
||||
- **String escaping**: User-provided strings (`name`, `description`, `comment`, version) were embedded directly into Turtle string literals without escaping. A class named `John"s Class` or a comment containing a backslash or newline produced unparseable output. Added `_escape_ttl_str()` static method (escapes `"`, `\`, `\n`, `\r`, `\t`) applied at every `rdfs:label`, `rdfs:comment`, and `owl:versionInfo` site.
|
||||
- **Null-check consistency**: All optional field reads now use `x = prop.get("field"); if x:` uniformly — eliminates the mixed pattern of `.get()` guards followed by direct `[]` access.
|
||||
- 43 tests added in `tests/export/test_owl_exporter.py` across five suites: `TestTurtleSyntaxValidity` (5), `TestDataPropertiesInTurtle` (8), `TestTurtleHeader` (4), `TestTurtleStringEscaping` (16), `TestNullFieldHandling` (7), plus `TestObjectPropertyListDomainRange` (2) and `TestEquivalentClass` (1).
|
||||
|
||||
- **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (0–1 hops), `"near"` (2–3), `"mid-range"` (4–6), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops).
|
||||
|
||||
- **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`).
|
||||
|
||||
- **Enhancement: Native `KnowledgeGraph` type support in `KGVisualizer`** (PR `kg` by @KaifAhmad1, closes #471): Added `semantica/kg/knowledge_graph.py` — a formal `KnowledgeGraph` dataclass (`entities`, `relationships`, `metadata`) that is now the canonical in-memory type produced and consumed by the Semantica KG pipeline. Exported from `semantica.kg`. `KGVisualizer` gains `_convert_knowledge_graph()` — an authoritative, non-mutating conversion path from `KnowledgeGraph` to the internal dict format — and `_normalize_graph()` now routes `isinstance(graph, KnowledgeGraph)` through it as an explicit fast-path before duck-typing. All five public entry points (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) accept `KnowledgeGraph` directly; no manual conversion required. All existing callers passing dicts or duck-typed objects are unaffected. 15 new tests in `TestFormalKnowledgeGraphType` (conversion shape, non-mutation, determinism, routing, all five entry points, import availability).
|
||||
- **Fix: `KGVisualizer` now accepts `KnowledgeGraph` objects in all `visualize_*` methods** (PR `visualization` by @KaifAhmad1, closes #458): All five public methods (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) previously called `graph.get("entities", [])`, silently producing no output when passed a non-dict object. Added `_normalize_graph()` which duck-types the input — dicts pass through unchanged; any object exposing `.entities` / `.relationships` attributes (e.g. the result of `GraphBuilder.build()`) is converted to the canonical dict form; anything else raises a clear `ProcessingError` naming the offending type. 21 tests added in `tests/visualization/test_kg_visualizer_normalize_graph.py`.
|
||||
|
||||
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
|
||||
|
||||
**Critical**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
FROM node:25-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/semantica-explorer
|
||||
|
||||
@@ -13,7 +13,7 @@ COPY semantica-explorer/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
FROM python:3.14-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
[](https://github.com/Hawksight-AI/semantica/actions)
|
||||
[](https://discord.gg/sV34vps5hH)
|
||||
[](https://x.com/BuildSemantica)
|
||||
[](https://openclaw.ai)
|
||||
|
||||
### ⭐ Give us a Star · 🍴 Fork us · 💬 Join our Discord · 🐦 Follow on X
|
||||
|
||||
@@ -45,7 +46,7 @@ Semantica is the **context and intelligence layer** you add on top of your exist
|
||||
- ✅ **Reasoning Engines** — forward chaining, Rete networks, deductive, abductive, and SPARQL. Explainable paths, not black boxes.
|
||||
- ✅ **Quality & Deduplication** — conflict detection, entity resolution, and pipeline validation built in.
|
||||
|
||||
> Works alongside LangChain, LlamaIndex, AutoGen, CrewAI, and any LLM — Semantica is the **accountability layer** on top, not a replacement.
|
||||
> Works alongside **Agno** and any LLM — Semantica is the **accountability layer** on top, not a replacement. LangChain, LangGraph, CrewAI, and more coming soon.
|
||||
|
||||
```bash
|
||||
pip install semantica
|
||||
@@ -53,17 +54,314 @@ pip install semantica
|
||||
|
||||
---
|
||||
|
||||
## Plugins (Claude, Cursor, Codex)
|
||||
## 🔌 Works With Every AI Tool
|
||||
|
||||
Semantica includes a cross-platform plugin bundle under `plugins/` for community use:
|
||||
Semantica ships **native plugin bundles** for Claude Code, Cursor, and Codex, an **MCP server** (`python -m semantica.mcp_server`) for Windsurf, Cline, Continue, VS Code, Claude Desktop, and OpenClaw, and a **REST API** (FastAPI, port 8000) for any other tool.
|
||||
|
||||
- 17 domain skills (context graphs, decision intelligence, explainability, reasoning, provenance, ontology, temporal, visualization)
|
||||
- Specialized agents (`decision-advisor`, `explainability`, `kg-assistant`)
|
||||
- Hook configuration and platform-specific manifests for Claude, Cursor, and Codex
|
||||
<table>
|
||||
|
||||
See the community setup guide:
|
||||
<!-- ── Native Plugin Bundle ──────────────────────────────────────────── -->
|
||||
<tr>
|
||||
<th colspan="3" align="left">🔌 Native Plugin Bundle</th>
|
||||
<th colspan="5" align="left">⚡ MCP Server + Plugin</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://claude.com/product/claude-code"><img src="https://github.com/anthropics.png?size=120" alt="Claude Code" width="48" height="48" /></a><br/>
|
||||
<strong>Claude Code</strong><br/>
|
||||
<sub>17 skills · 3 agents · hooks</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://cursor.com"><img src="https://www.freelogovectors.net/wp-content/uploads/2025/06/cursor-logo-freelogovectors.net_.png" alt="Cursor" width="48" height="48" /></a><br/>
|
||||
<strong>Cursor</strong><br/>
|
||||
<sub>17 skills · 3 agents</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/openai/codex"><img src="https://github.com/openai.png?size=120" alt="Codex CLI" width="48" height="48" /></a><br/>
|
||||
<strong>Codex CLI</strong><br/>
|
||||
<sub>17 skills · 3 agents</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://windsurf.com"><img src="https://exafunction.github.io/public/brand/windsurf-black-symbol.svg" alt="Windsurf" width="48" height="48" /></a><br/>
|
||||
<strong>Windsurf</strong><br/>
|
||||
<sub><a href="plugins/.windsurf-plugin/">plugin</a></sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/cline/cline"><img src="https://github.com/cline.png?size=120" alt="Cline" width="48" height="48" /></a><br/>
|
||||
<strong>Cline</strong><br/>
|
||||
<sub><a href="plugins/.cline-plugin/">plugin</a></sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/continuedev/continue"><img src="https://github.com/continuedev.png?size=120" alt="Continue" width="48" height="48" /></a><br/>
|
||||
<strong>Continue</strong><br/>
|
||||
<sub><a href="plugins/.continue-plugin/">plugin</a></sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/microsoft/vscode"><img src="https://github.com/microsoft.png?size=120" alt="VS Code" width="48" height="48" /></a><br/>
|
||||
<strong>VS Code</strong><br/>
|
||||
<sub><a href="plugins/.vscode-plugin/">plugin</a></sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="integrations/openclaw/"><img src="https://github.com/openclaw.png?size=120" alt="OpenClaw" width="48" height="48" /></a><br/>
|
||||
<strong>OpenClaw</strong><br/>
|
||||
<sub>MCP + <a href="integrations/openclaw/">plugin</a></sub>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
- [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
|
||||
<!-- ── MCP Server only · REST API ───────────────────────────────────── -->
|
||||
<tr>
|
||||
<th colspan="1" align="left">☁️ MCP Server</th>
|
||||
<th colspan="7" align="left">🌐 REST API</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://claude.ai/download"><img src="https://github.com/anthropics.png?size=120" alt="Claude Desktop" width="48" height="48" /></a><br/>
|
||||
<strong>Claude Desktop</strong><br/>
|
||||
<sub>MCP server</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/features/copilot"><img src="https://github.com/github.png?size=120" alt="GitHub Copilot" width="48" height="48" /></a><br/>
|
||||
<strong>GitHub Copilot</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/RooCodeInc/Roo-Code"><img src="https://github.com/RooCodeInc.png?size=120" alt="Roo Code" width="48" height="48" /></a><br/>
|
||||
<strong>Roo Code</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/block/goose"><img src="https://github.com/block.png?size=120" alt="Goose" width="48" height="48" /></a><br/>
|
||||
<strong>Goose</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/Kilo-Org/kilocode"><img src="https://github.com/Kilo-Org.png?size=120" alt="Kilo Code" width="48" height="48" /></a><br/>
|
||||
<strong>Kilo Code</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/Aider-AI/aider"><img src="https://github.com/Aider-AI.png?size=120" alt="Aider" width="48" height="48" /></a><br/>
|
||||
<strong>Aider</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/aws/amazon-q-developer-cli"><img src="https://github.com/aws.png?size=120" alt="Amazon Q" width="48" height="48" /></a><br/>
|
||||
<strong>Amazon Q</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://zed.dev"><img src="https://github.com/zed-industries.png?size=120" alt="Zed" width="48" height="48" /></a><br/>
|
||||
<strong>Zed</strong><br/>
|
||||
<sub>REST API</sub>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- ── Any tool via REST ─────────────────────────────────────────────── -->
|
||||
<tr>
|
||||
<th colspan="8" align="left">🔧 Any Tool</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="8">
|
||||
<img src="https://img.shields.io/badge/109-endpoints-1f6feb?style=flat-square" alt="REST API" width="48" /><br/>
|
||||
<strong>Any agent</strong><br/>
|
||||
<sub>109 REST endpoints · FastAPI · port 8000</sub>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
### Agentic Frameworks
|
||||
|
||||
Semantica integrates with **Agno** today. Coming soon: LangChain, LangGraph, CrewAI, LlamaIndex, AutoGen, OpenAI Agents SDK, Google ADK, and more.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th colspan="8" align="left">✅ Supported</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/agno-agi/agno"><img src="https://github.com/agno-agi.png?size=120" alt="Agno" width="48" height="48" /></a><br/>
|
||||
<strong>Agno</strong><br/>
|
||||
<sub>First-class · <code>pip install semantica[agno]</code></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="8" align="left">🔜 Coming Soon</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
|
||||
<strong>LangChain</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/langchain-ai/langgraph"><img src="https://github.com/langchain-ai.png?size=120" alt="LangGraph" width="48" height="48" /></a><br/>
|
||||
<strong>LangGraph</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/crewAIInc/crewAI"><img src="https://github.com/crewAIInc.png?size=120" alt="CrewAI" width="48" height="48" /></a><br/>
|
||||
<strong>CrewAI</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/run-llama/llama_index"><img src="https://github.com/run-llama.png?size=120" alt="LlamaIndex" width="48" height="48" /></a><br/>
|
||||
<strong>LlamaIndex</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/microsoft/autogen"><img src="https://github.com/microsoft.png?size=120" alt="AutoGen" width="48" height="48" /></a><br/>
|
||||
<strong>AutoGen</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/openai/openai-agents-python"><img src="https://github.com/openai.png?size=120" alt="OpenAI Agents SDK" width="48" height="48" /></a><br/>
|
||||
<strong>OpenAI Agents</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/google/adk-python"><img src="https://github.com/google.png?size=120" alt="Google ADK" width="48" height="48" /></a><br/>
|
||||
<strong>Google ADK</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> **Agno — First-Class Integration** · `pip install semantica[agno]`
|
||||
>
|
||||
> Five integration modules live in [`integrations/agno/`](integrations/agno/):
|
||||
>
|
||||
> | Module | Class | What it does |
|
||||
> |---|---|---|
|
||||
> | `context_store.py` | `AgnoContextStore` | Graph-backed agent memory — store and retrieve structured context |
|
||||
> | `knowledge_graph.py` | `AgnoKnowledgeGraph` | Implements Agno's `AgentKnowledge` protocol; full extraction pipeline |
|
||||
> | `decision_kit.py` | `AgnoDecisionKit` | 6 decision-intelligence tools for Agno agents |
|
||||
> | `kg_toolkit.py` | `AgnoKGToolkit` | 7 KG pipeline tools (build, query, enrich, export) |
|
||||
> | `shared_context.py` | `AgnoSharedContext` | Shared context graph for multi-agent team coordination |
|
||||
|
||||
### Plugin Bundles (Claude Code · Cursor · Codex)
|
||||
|
||||
Native plugin bundles live under [`plugins/`](plugins/). Each directory contains a `plugin.json`, `marketplace.json`, and `README.md`.
|
||||
|
||||
| Bundle | Directory | Tools |
|
||||
|---|---|---|
|
||||
| Claude Code | [`plugins/.claude-plugin/`](plugins/.claude-plugin/) | 17 skills · 3 agents · hooks |
|
||||
| Cursor | [`plugins/.cursor-plugin/`](plugins/.cursor-plugin/) | 17 skills · 3 agents · hooks |
|
||||
| Codex CLI | [`plugins/.codex-plugin/`](plugins/.codex-plugin/) | 17 skills · 3 agents |
|
||||
| Windsurf | [`plugins/.windsurf-plugin/`](plugins/.windsurf-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
| Cline | [`plugins/.cline-plugin/`](plugins/.cline-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
| Continue | [`plugins/.continue-plugin/`](plugins/.continue-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
| VS Code | [`plugins/.vscode-plugin/`](plugins/.vscode-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
| OpenClaw | [`plugins/.openclaw-plugin/`](plugins/.openclaw-plugin/) | 17 skills · 3 agents · MCP config |
|
||||
|
||||
**17 domain skills:**
|
||||
|
||||
| Skill | What it does |
|
||||
|---|---|
|
||||
| `extract` | Full semantic extraction pipeline: NER, relations, events, coreference, triplets |
|
||||
| `ingest` | Data ingestion from files, databases, APIs, streams, and MCP servers |
|
||||
| `query` | SPARQL, Cypher, keyword search, structured graph patterns |
|
||||
| `ontology` | Schema management, concepts, relationships, alignments |
|
||||
| `validate` | Pipeline, extraction, schema, and ontology validation |
|
||||
| `deduplicate` | Duplicate detection and entity merging with fuzzy matching |
|
||||
| `embed` | Node2Vec embeddings, similarity scoring, link prediction |
|
||||
| `reason` | Deductive, abductive, Datalog, SPARQL, and Rete reasoning engines |
|
||||
| `decision` | Record, query, and analyze decisions; find precedents; causal analysis |
|
||||
| `causal` | Cause-effect chains, interventions, counterfactuals, causal influence |
|
||||
| `temporal` | Point-in-time queries, snapshots, timelines, temporal causal analysis |
|
||||
| `provenance` | Data lineage, source attribution, audit trails |
|
||||
| `policy` | Policy definition, enforcement, compliance checks, access control |
|
||||
| `explain` | Decision logic transparency, causal context, audit-ready explanations |
|
||||
| `export` | Multi-format export: JSON, RDF, Parquet, CSV, GraphML |
|
||||
| `change` | Graph change tracking, diffs, temporal updates, impact analysis |
|
||||
| `visualize` | Topology, centrality, communities, paths, embeddings, decision graphs |
|
||||
|
||||
**3 specialized agents:**
|
||||
|
||||
| Agent | Role |
|
||||
|---|---|
|
||||
| `kg-assistant` | General-purpose KG-aware assistant — knows all APIs and method signatures |
|
||||
| `decision-advisor` | Decision intelligence specialist: causal reasoning, precedents, policy violations |
|
||||
| `explainability` | Reasoning transparency specialist — generates audit-ready explanation reports |
|
||||
|
||||
**Hooks** (`plugins/hooks/hooks.json`) — `PreToolUse` / `PostToolUse` matchers for syntax validation and automated warnings.
|
||||
|
||||
→ [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
|
||||
|
||||
### MCP Server (expose Semantica to any MCP-aware tool)
|
||||
|
||||
Semantica ships a full **MCP server** (`semantica/mcp_server.py`) — run it once and any MCP-compatible tool connects automatically:
|
||||
|
||||
```bash
|
||||
python -m semantica.mcp_server
|
||||
```
|
||||
|
||||
Add to your tool's config (Claude Desktop, Windsurf, Cline, Continue, VS Code, Roo Code):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "semantica.mcp_server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**12 tools exposed:** `extract_entities`, `extract_relations`, `record_decision`, `query_decisions`, `find_precedents`, `get_causal_chain`, `add_entity`, `add_relationship`, `run_reasoning`, `get_graph_analytics`, `export_graph`, `get_graph_summary`
|
||||
|
||||
**3 resources:** `semantica://graph/summary`, `semantica://decisions/list`, `semantica://schema/info`
|
||||
|
||||
See [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md) for per-tool config snippets.
|
||||
|
||||
### MCP Client (Ingest from MCP Servers)
|
||||
|
||||
Semantica also includes an **MCP client** (`semantica/ingest/mcp_client.py`) that lets you pull data from any Python/FastMCP server into a knowledge graph:
|
||||
|
||||
```python
|
||||
from semantica.ingest import MCPClient
|
||||
|
||||
client = MCPClient("http://your-mcp-server:8080")
|
||||
resources = client.list_resources() # discover available resources
|
||||
data = client.read_resource("resource://your-data")
|
||||
```
|
||||
|
||||
Supported connection schemes: `http://`, `https://`, `mcp://`, `sse://` · JSON-RPC · auth support · dynamic capability discovery.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Semantica Knowledge Explorer
|
||||
|
||||
A real-time visual interface for exploring every dimension of your knowledge graph — built into the repo under [`explorer/`](explorer/).
|
||||
|
||||
| Workspace | What you can do |
|
||||
|---|---|
|
||||
| **Knowledge Graph** | Pan, zoom, and inspect a live Sigma.js graph canvas with ForceAtlas2 layout |
|
||||
| **Timeline** | Scrub through temporal events and watch the graph evolve |
|
||||
| **Decisions** | Browse the causal chain behind every recorded decision with outcome badges |
|
||||
| **Registry** | Live audit log of every graph mutation — add-node, add-edge, merge, delete |
|
||||
| **Entity Resolution** | Review and merge duplicate entities detected by the deduplication engine |
|
||||
| **KG Overview** | Aggregate stats, community breakdown, centrality heatmap |
|
||||
| **Ontology** | SKOS/OWL vocabulary hierarchy and auto-generated schema summary |
|
||||
|
||||
### Run locally
|
||||
|
||||
```bash
|
||||
# 1. Start the Semantica backend (port 8000)
|
||||
python -m semantica.server
|
||||
|
||||
# 2. In a second terminal
|
||||
cd explorer
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open **http://localhost:5173** — the Explorer connects automatically. All `/api` and `/ws` traffic is proxied to `127.0.0.1:8000` by Vite, so no CORS configuration is needed.
|
||||
|
||||
> **Requirements:** Node 18+ · Python 3.8+ · npm 9+
|
||||
|
||||
For the full setup guide, troubleshooting, and production build instructions see [`explorer/README.md`](explorer/README.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -347,6 +645,7 @@ Semantic memory with hybrid search and metadata filtering.
|
||||
| `semantica.change_management` | Version storage, change tracking, checksums, audit trails, compliance support for KGs and ontologies |
|
||||
| `semantica.triplet_store` | RDF triplet store integration — Blazegraph, Jena, RDF4J; SPARQL queries and bulk loading |
|
||||
| `semantica.visualization` | Interactive and static visualization of KGs, ontologies, embeddings, analytics, and temporal graphs |
|
||||
| [`explorer/`](explorer/) | **Semantica Knowledge Explorer** — React 19 + Sigma.js UI: graph canvas, decision viewer, causal chains, entity resolution, ontology browser, and registry audit log |
|
||||
| `semantica.seed` | Seed data management for initial KG construction from CSV, JSON, databases, and APIs |
|
||||
| `semantica.core` | Framework orchestration, configuration management, knowledge base construction, plugin system |
|
||||
| `semantica.llms` | LLM provider integrations — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM |
|
||||
@@ -643,16 +942,13 @@ if result.valid:
|
||||
- **`semantica.triplet_store`** — Blazegraph, Jena, RDF4J; SPARQL, bulk loading, SKOS helpers
|
||||
- **`semantica.visualization`** — KG, ontology, embedding, and temporal graph visualization
|
||||
- **`semantica.llms`** — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Integrations
|
||||
- **[`explorer/`](explorer/)** — **Semantica Knowledge Explorer** — browser UI for live graph inspection, decisions, entity resolution, and ontology browsing (`npm run dev` in `explorer/`)
|
||||
|
||||
### Graph Databases
|
||||
- **AWS Neptune** — Amazon Neptune with IAM authentication
|
||||
- **Neo4j** — Cypher queries via `semantica.graph_store`
|
||||
- **FalkorDB** — native support; `DecisionQuery` and `CausalChainAnalyzer` work directly with FalkorDB row/header shapes
|
||||
- **Apache AGE** — PostgreSQL + openCypher via SQL
|
||||
- **FalkorDB** — native support for decision queries and causal analysis
|
||||
- **AWS Neptune** — Amazon Neptune with IAM authentication
|
||||
|
||||
### Vector Databases
|
||||
- **FAISS** — built-in, zero extra dependencies
|
||||
@@ -668,22 +964,15 @@ if result.valid:
|
||||
- **Databases** — SQL via `DBIngestor`
|
||||
- **Snowflake** — table/query ingestion, pagination, password/key-pair/OAuth/SSO auth · `pip install semantica[db-snowflake]`
|
||||
- **Docling** — advanced table and layout extraction (PDF, DOCX, PPTX, XLSX)
|
||||
- **Email** — inbox ingestion via `EmailIngestor`
|
||||
- **Repositories** — Git repo ingestion for code graph construction
|
||||
|
||||
### LLM Providers
|
||||
- **LiteLLM** — 100+ models: OpenAI, Anthropic, Cohere, Mistral, Ollama, Azure, AWS Bedrock, and more
|
||||
- **Novita AI** — OpenAI-compatible (`deepseek/deepseek-v3.2` and more) · set `NOVITA_API_KEY`
|
||||
|
||||
### Agentic Frameworks
|
||||
Semantica complements — not replaces — LangChain, LlamaIndex, AutoGen, CrewAI, Google ADK, and more.
|
||||
|
||||
> **Agno — First-Class Integration** · `pip install semantica[agno]`
|
||||
>
|
||||
> Five ready-to-use Agno components:
|
||||
> - `AgnoContextStore` — graph-backed agent memory
|
||||
> - `AgnoKnowledgeGraph` — multi-hop GraphRAG knowledge base
|
||||
> - `AgnoDecisionKit` — 6 decision-intelligence tools
|
||||
> - `AgnoKGToolkit` — 7 KG pipeline tools
|
||||
> - `AgnoSharedContext` — shared context graph for multi-agent teams
|
||||
- **Groq** — ultra-low latency inference · set `GROQ_API_KEY`
|
||||
- **HuggingFace** — local and hosted models via `HuggingFaceProvider`
|
||||
- **Ollama** — local models including remote server support
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# Semantica v0.3.0 — Release Notes
|
||||
|
||||
**Released:** 2026-03-10
|
||||
**PyPI:** `pip install semantica`
|
||||
**Tag:** [v0.3.0](https://github.com/Hawksight-AI/semantica/releases/tag/v0.3.0)
|
||||
**Classification:** Production/Stable
|
||||
|
||||
> First stable, full public release of Semantica. Covers everything shipped across three release stages: 0.3.0-alpha (2026-02-19), 0.3.0-beta (2026-03-07), and 0.3.0 stable (2026-03-10).
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
| Contributor | Role |
|
||||
|------------|------|
|
||||
| [@KaifAhmad1](https://github.com/KaifAhmad1) | Lead maintainer — context graph, decision intelligence, KG algorithms, semantic extraction, pipeline, provenance, bug fixes, release management |
|
||||
| [@ZohaibHassan16](https://github.com/ZohaibHassan16) | Deduplication v2 suite (candidate generation, two-stage scoring, semantic dedup), incremental/delta processing, benchmark suite |
|
||||
| [@Sameer6305](https://github.com/Sameer6305) | Apache AGE backend, PgVector store, Snowflake connector, Apache Arrow export |
|
||||
| [@tibisabau](https://github.com/tibisabau) | ArangoDB AQL export, Apache Parquet export |
|
||||
| [@d4ndr4d3](https://github.com/d4ndr4d3) | ResourceScheduler deadlock fix |
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 — Stable (2026-03-10)
|
||||
|
||||
### Context Graph Feature Completeness
|
||||
|
||||
**Temporal Validity Windows** (by @KaifAhmad1)
|
||||
|
||||
Nodes and edges now carry first-class `valid_from` / `valid_until` ISO datetime fields. These are stored directly on `ContextNode` and `ContextEdge` dataclasses — not in metadata — and survive full serialisation round-trips through `save_to_file()` / `load_from_file()` and `to_dict()` / `from_dict()`.
|
||||
|
||||
- `ContextNode.is_active(at_time=None)` and `ContextEdge.is_active(at_time=None)` — returns `True` if the node/edge is live at the given time (defaults to now). Handles both tz-aware and tz-naive datetime inputs correctly.
|
||||
- `ContextGraph.find_active_nodes(node_type=None, at_time=None)` — filters the entire graph and returns only nodes within their validity window.
|
||||
- `add_node(valid_from=..., valid_until=...)` and `add_edge(valid_from=..., valid_until=...)` — pass validity fields directly in the call signature.
|
||||
- Bug fix: `is_active()` previously crashed with `TypeError` when passed a tz-aware `datetime` (e.g. `datetime.now(timezone.utc)`). Fixed by normalising all inputs to tz-naive UTC via a new `_parse_iso_dt()` helper.
|
||||
- Bug fix: validity fields were silently lost in `add_nodes()`, `add_edges()`, `to_dict()`, and `from_dict()`. All four paths now correctly preserve and restore them.
|
||||
|
||||
**Weighted Multi-Hop BFS** (by @KaifAhmad1)
|
||||
|
||||
`ContextGraph.get_neighbors(node_id, hops=1, relationship_types=None, min_weight=0.0)` now accepts a `min_weight` threshold. Any edge with weight below the threshold is skipped during BFS traversal, allowing callers to confine multi-hop queries to high-confidence causal links. Default `0.0` is fully backward-compatible.
|
||||
|
||||
**Cross-Graph Navigation** (by @KaifAhmad1)
|
||||
|
||||
Separate `ContextGraph` instances can now be linked and navigated between — hierarchically, like separate knowledge domains that reference each other.
|
||||
|
||||
- `link_graph(other_graph, source_node_id, target_node_id, link_type="CROSS_GRAPH") -> str` — creates a navigable bridge and returns a `link_id`. Records a dedicated `"cross_graph_link"` typed marker node internally (not a phantom `"entity"`) and a marker edge.
|
||||
- `navigate_to(link_id) -> (other_graph, target_node_id)` — jumps to the target graph and entry node for a given link.
|
||||
- `graph_id` field — each `ContextGraph` now carries a stable UUID so instances can identify each other across save/load.
|
||||
- `save_to_file()` — now writes a `links` section alongside nodes and edges, containing `link_id`, `source_node_id`, `target_node_id`, and `other_graph_id` for every cross-graph link.
|
||||
- `load_from_file()` — restores `graph_id` and populates `_unresolved_links` from the `links` section.
|
||||
- `resolve_links(registry: Dict[str, ContextGraph]) -> int` — reconnects unresolved links post-load. Pass `{graph_id: graph_instance}` for each linked graph; returns the count of successfully resolved links. `navigate_to()` raises a clear `KeyError` with a `resolve_links()` hint if called before resolution.
|
||||
- Bug fix: the previous implementation auto-created the synthetic marker target as an `"entity"` node (phantom pollution). Fixed by explicitly pre-creating a `"cross_graph_link"` typed `ContextNode` before the marker edge.
|
||||
- 14 new tests in `tests/context/test_cross_graph_navigation.py` covering all scenarios including full save/load round-trips with partial registry resolution.
|
||||
|
||||
**Other Fixes** (by @KaifAhmad1)
|
||||
|
||||
- `PipelineBuilder.add_step()` return type annotation corrected from `"PipelineBuilder"` to `"PipelineStep"` — the implementation was already correct; only the annotation and docstring were stale.
|
||||
- `test_hybrid_search_performance` timing computation fixed — now accumulates a true `search_times` list instead of reusing the last loop iteration's `start_time`; threshold relaxed to `< 5.0s` for real `sentence-transformers` (384-dim) latency on development machines.
|
||||
|
||||
**Test Coverage Added**
|
||||
|
||||
- 14 cross-graph navigation tests (`tests/context/test_cross_graph_navigation.py`)
|
||||
- **Total: 335 context tests, 886+ tests across all modules — 0 failures**
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0-beta — Beta (2026-03-07)
|
||||
|
||||
### Semantic Extraction Fixes
|
||||
|
||||
**Multi-Founder LLM Extraction & Reasoner Inference Fix** (PR #354, by @KaifAhmad1)
|
||||
|
||||
- `_parse_relation_result` in `methods.py` — unmatched subjects/objects now produce a synthetic `UNKNOWN` entity instead of silently dropping the relation. All co-founders returned by the LLM are preserved in the output.
|
||||
- Duplicate relation fix — an orphaned legacy block that appended every relation twice has been removed.
|
||||
- `extraction_method` parameter added — typed extraction paths now correctly record `"llm_typed"` in relation metadata instead of `"llm"`.
|
||||
- `_match_pattern` in `reasoner.py` rewritten — splits patterns on `?var` placeholders first, then escapes only literal segments. Pre-bound variables resolve to exact literals, repeated variables use backreferences, non-greedy `.+?` prevents over-consumption of separators.
|
||||
- Added `tests/reasoning/test_reasoner.py` (4 tests) and `tests/semantic_extract/test_relation_extractor.py` (6 tests).
|
||||
|
||||
**TTL Export Alias Fix** (PR #355, by @KaifAhmad1)
|
||||
|
||||
- `RDFExporter` now accepts `"ttl"`, `"nt"`, `"xml"`, `"rdf"`, and `"json-ld"` as format aliases in `export_to_rdf()`. Aliases resolve before format validation — zero public API changes.
|
||||
- Added `tests/export/test_rdf_exporter.py` (8 tests).
|
||||
|
||||
### Incremental / Delta Processing
|
||||
|
||||
**Native Delta Computation** (PR #349, by @ZohaibHassan16, reviewed and fixed by @KaifAhmad1)
|
||||
|
||||
- Native SPARQL-based diff between graph snapshots — only changed triples flow through the pipeline.
|
||||
- `delta_mode` configuration in `PipelineBuilder` for near-real-time workloads.
|
||||
- Version snapshot management with graph URI tracking and metadata storage.
|
||||
- `prune_versions()` for automatic snapshot retention cleanup.
|
||||
- Bug fixes: corrected SPARQL variable order, fixed class references, resolved duplicate dictionary keys.
|
||||
|
||||
### Deduplication v2
|
||||
|
||||
**Candidate Generation v2** (PR #338, by @ZohaibHassan16)
|
||||
|
||||
- New opt-in strategies: `blocking_v2` and `hybrid_v2`, replacing O(N²) pair enumeration.
|
||||
- Multi-key blocking with normalised token prefixes, type-aware keys, and optional phonetic (Soundex) blocking.
|
||||
- Deterministic `max_candidates_per_entity` budgeting with stable sorting.
|
||||
- **63.6% faster** in worst-case scenarios (0.259s → 0.094s for 100 entities).
|
||||
|
||||
**Two-Stage Scoring Prefilter** (PR #339, by @ZohaibHassan16)
|
||||
|
||||
- Fast gates for type mismatch, name-length ratio, and token overlap eliminate expensive semantic scoring for obvious non-matches.
|
||||
- Configurable thresholds: `min_length_ratio`, `min_token_overlap_ratio`, `required_shared_token`.
|
||||
- **18–25% faster** batch processing with prefilter enabled (`prefilter_enabled=False` by default).
|
||||
|
||||
**Semantic Relationship Deduplication v2** (PR #340, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
||||
|
||||
- Canonicalisation engine with predicate synonym mapping (`works_for` → `employed_by`).
|
||||
- O(1) hash matching for exact canonical signatures.
|
||||
- Weighted scoring: 60% predicate + 40% object composition with explainable `semantic_match_score`.
|
||||
- **6.98x faster** than legacy mode (83ms vs 579ms).
|
||||
- `dedup_triplets()` infinite recursion bug fixed; function is now a first-class API in `methods.py`.
|
||||
|
||||
**Deduplication v2 Migration Guide** (PR #344, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
||||
|
||||
- Comprehensive `MIGRATION_V2.md` documenting all v2 strategies with code examples.
|
||||
- Full backward compatibility maintained — legacy mode remains the default.
|
||||
|
||||
### Export Formats
|
||||
|
||||
**ArangoDB AQL Export** (PR #342, by @tibisabau)
|
||||
|
||||
- Full AQL INSERT statement generation for vertices and edges.
|
||||
- Configurable collection names with validation and sanitisation; batch processing (default: 1000).
|
||||
- `export_arango()` convenience function; `.aql` auto-detection in the unified exporter.
|
||||
- 17 tests, 100% pass rate.
|
||||
|
||||
**Apache Parquet Export** (PR #343, by @tibisabau)
|
||||
|
||||
- Columnar storage format with configurable compression: snappy, gzip, brotli, zstd, lz4, none.
|
||||
- Explicit Apache Arrow schemas with type safety; field normalisation for varied naming conventions.
|
||||
- `export_parquet()` convenience function; `.parquet` auto-detection.
|
||||
- Analytics-ready for pandas, Spark, Snowflake, BigQuery, Databricks.
|
||||
- 25 tests, 100% pass rate.
|
||||
|
||||
### Bug Fixes & Test Suite Stabilisation
|
||||
|
||||
**Test Suite Fixes** (by @KaifAhmad1)
|
||||
|
||||
Context module:
|
||||
- `retrieve_decision_precedents` — gated entity extraction on `use_hybrid_search=True` correctly.
|
||||
- `_extract_entities_from_query` — now uses `word[0].isupper()` to capture camelCase identifiers like `CreditCard`.
|
||||
- Added missing `expand_context` (BFS traversal) and `_get_decision_query` methods.
|
||||
- Fixed `hybrid_retrieval`, `dynamic_context_traversal`, and `multi_hop_context_assembly` for correct single-pass BFS.
|
||||
- Fixed `_retrieve_from_vector` fallback to `result["metadata"]["content"]` to prevent empty content and negative re-ranking scores.
|
||||
|
||||
KG module:
|
||||
- `calculate_pagerank` — added `alpha`/`max_iter` aliases; return format changed to `{"centrality": scores, "rankings": sorted_list}`.
|
||||
- `community_detector._to_networkx` — now returns a NetworkX graph directly when one is passed (previously lost all edges).
|
||||
- Added 9 domain-specific tracking methods to `AlgorithmTrackerWithProvenance`.
|
||||
- Created `provenance_tracker.py` with `ProvenanceTracker` (`track_entity`, `get_all_sources`, `clear`).
|
||||
|
||||
Pipeline module:
|
||||
- Retry loop fixed — now correctly iterates to `max_retries`.
|
||||
- `RecoveryAction` dataclass and `handle_failure(error, policy, retry_count)` added with LINEAR, EXPONENTIAL, and FIXED strategies.
|
||||
- `add_step()` fixed to return the created `PipelineStep`.
|
||||
- `validate` added as alias for `validate_pipeline` in `PipelineValidator`.
|
||||
|
||||
Other:
|
||||
- Fixed `NameError` for missing `Type` import in `utils/helpers.py`.
|
||||
- Vector store performance threshold relaxed (100ms → 500ms per decision for development machines).
|
||||
- Windows cp1252 encoding fix in test files (emoji → ASCII).
|
||||
- `ProvenanceTracker` added to `semantica/kg/__init__.py` exports.
|
||||
|
||||
**Results: ~840 tests passing, 36 skipped (external services), 0 failed**
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0-alpha — Alpha (2026-02-19)
|
||||
|
||||
### Context & Decision Intelligence
|
||||
|
||||
**Context Engineering Enhancement** (PR #307, by @KaifAhmad1)
|
||||
|
||||
The foundational 0.3.0 feature — complete overhaul of the context module for production-grade decision intelligence:
|
||||
|
||||
- Full decision lifecycle: `record_decision()` → `add_decision()` → `add_causal_relationship()` → `trace_decision_chain()` → `analyze_decision_impact()` → `analyze_decision_influence()` → `find_similar_decisions()`
|
||||
- `AgentContext` unified wrapper with granular feature flags: `decision_tracking`, `kg_algorithms`, `graph_expansion`; methods: `store()`, `retrieve()`, `get_conversation_history()`, `get_statistics()`, `capture_cross_system_inputs()`
|
||||
- `AgentMemory` with working, conversation, and long-term memory tiers
|
||||
- `PolicyEngine` with versioned policy nodes, compliance checking (`check_decision_rules()`), and `PolicyException` model
|
||||
- Hybrid precedent search combining vector, structural, and category similarity with configurable weights
|
||||
- Decision influence analysis via centrality measures and causal chain tracking
|
||||
- GraphStore validation preventing runtime failures; secure logging
|
||||
- 9 critical bug fixes across logging, security, audit trails, API compatibility, Cypher queries, centrality access, validation
|
||||
|
||||
**Context Decision Tracking Fixes** (PR #315, by @KaifAhmad1)
|
||||
|
||||
- Fixed empty/None decision ID handling in `add_decision()`
|
||||
- Fixed None metadata handling preventing `TypeError`
|
||||
- Fixed causal chain depth logic and node exclusion
|
||||
- Fixed nonexistent node handling in `add_causal_relationship()`
|
||||
- Fixed precedent search direction in `find_precedents()`
|
||||
- Added missing `properties` field in `to_dict()`; added `from_dict()` method
|
||||
- Fixed UUID generation across all decision models
|
||||
- All 71 context tests passing
|
||||
|
||||
### Knowledge Graph Algorithms
|
||||
|
||||
**Improved Graph Algorithms** (PR #292, by @KaifAhmad1)
|
||||
|
||||
- 30+ graph algorithms across 7 categories
|
||||
- Node embeddings: Node2Vec, DeepWalk, Word2Vec via `NodeEmbedder`
|
||||
- Similarity: cosine, Euclidean, Manhattan, Correlation via `SimilarityCalculator`
|
||||
- Path finding: Dijkstra, A*, BFS, K-shortest paths via `PathFinder`
|
||||
- Link prediction: preferential attachment, Jaccard, Adamic-Adar via `LinkPredictor`
|
||||
- Centrality: degree, betweenness, closeness, PageRank via `CentralityAnalyzer`
|
||||
- Community detection: Louvain, Leiden, label propagation via `CommunityDetector`
|
||||
- Connectivity: components, bridges, density via `ConnectivityAnalyzer`
|
||||
- `GraphBuilderWithProvenance` and `AlgorithmTrackerWithProvenance` with full execution metadata
|
||||
|
||||
**Improved Vector Store for Decision Tracking** (PR #293, by @KaifAhmad1)
|
||||
|
||||
- `DecisionEmbeddingPipeline` with semantic and structural embeddings
|
||||
- `HybridSimilarityCalculator` with configurable weights (semantic: 0.7, structural: 0.3)
|
||||
- `ContextRetriever` with multi-hop reasoning
|
||||
- Convenience API: `quick_decision()`, `find_precedents()`, `explain()`, `similar_to()`, `batch_decisions()`, `filter_decisions()`
|
||||
- 34+ tests; performance: 0.028s per decision, 0.031s search, ~0.8KB memory per decision
|
||||
|
||||
### Graph Database Backends
|
||||
|
||||
**Apache AGE Backend Security Fixes** (PR #311, by @Sameer6305, fixes by @KaifAhmad1)
|
||||
|
||||
- `AgeStore` class with `GraphStore` API compatibility (openCypher via SQL on PostgreSQL)
|
||||
- SQL injection vulnerabilities fixed with input validation
|
||||
- psycopg2-binary dependency and migration guide added
|
||||
- Fixed parameter replacement and test mock leakage
|
||||
|
||||
**PgVector Store Support** (PR #303, by @Sameer6305, @KaifAhmad1)
|
||||
|
||||
- Native PostgreSQL vector storage using the pgvector extension
|
||||
- Multiple distance metrics: cosine, L2/Euclidean, inner product
|
||||
- HNSW and IVFFlat indexing for approximate nearest neighbour search
|
||||
- JSONB metadata storage with flexible filtering; batch operations
|
||||
- Connection pooling with psycopg3/psycopg2 fallback
|
||||
- SQL injection protection via `psycopg_sql.SQL()`; idempotent index and table management
|
||||
- 36+ tests with Docker integration
|
||||
|
||||
### Infrastructure
|
||||
|
||||
**ResourceScheduler Deadlock Fix** (PR #299, #301, by @d4ndr4d3, @KaifAhmad1)
|
||||
|
||||
- Replaced `threading.Lock()` with `threading.RLock()` to fix nested lock acquisition deadlock in `allocate_resources()`
|
||||
- Added `ValidationError` when no resources can be allocated
|
||||
- Progress tracking updates moved outside lock scope
|
||||
- 6 regression tests for deadlock prevention
|
||||
|
||||
**Security Configuration** (by @KaifAhmad1)
|
||||
|
||||
- Dependabot bi-weekly security updates with manual review
|
||||
- Automated security scans (Bandit, Safety, Semgrep) on schedule
|
||||
- Security-critical package grouping; zero auto-merge policy
|
||||
|
||||
---
|
||||
|
||||
## Summary by the Numbers
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total tests passing | **886+** |
|
||||
| Test failures | **0** |
|
||||
| Context tests | 335 |
|
||||
| KG tests | ~430 |
|
||||
| Semantic extraction tests | 70 (9 skipped — external LLM APIs) |
|
||||
| Reasoning tests | 19 |
|
||||
| Real-world scenario tests | 85 |
|
||||
| PyPI classifier | Production/Stable |
|
||||
| Python support | 3.8 – 3.12 |
|
||||
|
||||
---
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
pip install --upgrade semantica
|
||||
```
|
||||
|
||||
No breaking changes. All new parameters have safe defaults and all new methods are additive.
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the full line-by-line diff.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Deduplication & Conflict Resolution Strategies Summary
|
||||
|
||||
## Quick Reference by Use Case
|
||||
|
||||
| Use Case | Deduplication Method | Merge Strategy | Conflict Detection | Conflict Resolution |
|
||||
|----------|---------------------|----------------|-------------------|---------------------|
|
||||
| **Finance** |
|
||||
| `01_Financial_Data_Integration_MCP` | `DuplicateDetector` (incremental) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||
| `02_Fraud_Detection` | `ClusterBuilder` (graph_based) | `merge_all` | `logical` | `expert_review` |
|
||||
| **Biomedical** |
|
||||
| `01_Drug_Discovery_Pipeline` | `EntityResolver` (semantic) | - | `relationship` | `voting` |
|
||||
| `02_Genomic_Variant_Analysis` | `DuplicateDetector` (group) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||
| **Cybersecurity** |
|
||||
| `01_Real_Time_Anomaly_Detection` | `DuplicateDetector` (pairwise) | `keep_first` | `entity` | `first_seen` |
|
||||
| `02_Threat_Intelligence_Hybrid_RAG` | `EntityResolver` (exact) | - | `type` | `highest_confidence` |
|
||||
| **Blockchain** |
|
||||
| `01_DeFi_Protocol_Intelligence` | `DuplicateDetector` (group) | `keep_last` | `relationship` | `voting` |
|
||||
| `02_Transaction_Network_Analysis` | `ClusterBuilder` (hierarchical) | `keep_most_complete` | `temporal` | `most_recent` |
|
||||
| **Intelligence** |
|
||||
| `01_Criminal_Network_Analysis` | `EntityResolver` (fuzzy) | - | `value` | `credibility_weighted` |
|
||||
| `02_Intelligence_Analysis_Orchestrator_Worker` | `DuplicateDetector` (batch) | `merge_all` | `entity` | `voting` |
|
||||
| **Renewable Energy** |
|
||||
| `01_Energy_Market_Analysis` | `DuplicateDetector` (pairwise) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||
| **Supply Chain** |
|
||||
| `01_Supply_Chain_Data_Integration` | `DuplicateDetector` (incremental) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||
|
||||
---
|
||||
|
||||
## Strategy Rationale by Domain
|
||||
|
||||
### Finance
|
||||
- **Financial Data Integration**: Incremental for streaming data; most_recent for time-sensitive financial data
|
||||
- **Fraud Detection**: Graph-based clustering for fraud groups; expert_review for fraud assessment
|
||||
|
||||
### Biomedical
|
||||
- **Drug Discovery**: Semantic matching for drug compounds; voting for research source aggregation
|
||||
- **Genomic Variants**: Group method for related variants; credibility weighting for research sources
|
||||
|
||||
### Cybersecurity
|
||||
- **Real-Time Anomaly**: Pairwise for real-time streams; keep_first for first detection priority
|
||||
- **Threat Intelligence**: Exact matching for IOCs; highest_confidence for threat classification
|
||||
|
||||
### Blockchain
|
||||
- **DeFi Protocols**: Group method for related protocols; keep_last for latest protocol info
|
||||
- **Transaction Networks**: Hierarchical clustering for nested groups; temporal for time-sensitive data
|
||||
|
||||
### Intelligence
|
||||
- **Criminal Networks**: Fuzzy matching for intelligence data; credibility weighting for intelligence sources
|
||||
- **Intelligence Analysis**: Batch for multi-source integration; merge_all to combine all intelligence sources
|
||||
|
||||
### Renewable Energy
|
||||
- **Energy Markets**: Pairwise for real-time market data; most_recent for time-sensitive energy data
|
||||
|
||||
### Supply Chain
|
||||
- **Supply Chain Integration**: Incremental for continuous updates; credibility weighting for supply chain sources
|
||||
|
||||
---
|
||||
|
||||
## Method Distribution
|
||||
|
||||
### Deduplication Methods (9 total)
|
||||
- `pairwise`: 2 notebooks (real-time processing)
|
||||
- `batch`: 3 notebooks (large datasets)
|
||||
- `incremental`: 2 notebooks (streaming/continuous)
|
||||
- `group`: 2 notebooks (related entities)
|
||||
- `graph_based` (ClusterBuilder): 2 notebooks (interconnected entities)
|
||||
- `hierarchical` (ClusterBuilder): 1 notebook (nested groups)
|
||||
- `exact` (EntityResolver): 1 notebook (exact matching)
|
||||
- `semantic` (EntityResolver): 2 notebooks (semantic similarity)
|
||||
- `fuzzy` (EntityResolver): 1 notebook (fuzzy matching)
|
||||
|
||||
### Merge Strategies (5 total)
|
||||
- `keep_first`: 1 notebook (first detection priority)
|
||||
- `keep_last`: 1 notebook (latest information)
|
||||
- `keep_most_complete`: 5 notebooks (preserve all details)
|
||||
- `keep_highest_confidence`: 2 notebooks (most reliable data)
|
||||
- `merge_all`: 3 notebooks (combine all information)
|
||||
|
||||
### Conflict Detection Methods (6 total)
|
||||
- `value`: 4 notebooks (property value conflicts)
|
||||
- `type`: 2 notebooks (type/classification conflicts)
|
||||
- `entity`: 2 notebooks (entity-wide conflicts)
|
||||
- `relationship`: 3 notebooks (relationship conflicts)
|
||||
- `temporal`: 3 notebooks (time-sensitive conflicts)
|
||||
- `logical`: 2 notebooks (logical inconsistencies)
|
||||
|
||||
### Conflict Resolution Strategies (6 total)
|
||||
- `voting`: 5 notebooks (majority vote)
|
||||
- `credibility_weighted`: 4 notebooks (source credibility)
|
||||
- `most_recent`: 3 notebooks (latest data)
|
||||
- `first_seen`: 1 notebook (first detection)
|
||||
- `highest_confidence`: 2 notebooks (most confident)
|
||||
- `expert_review`: 1 notebook (manual review)
|
||||
|
||||
---
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Real-Time Systems**: Use `pairwise` + `keep_first` + `first_seen`
|
||||
2. **Time-Sensitive Data**: Use `temporal` + `most_recent`
|
||||
3. **Multi-Source Integration**: Use `batch` + `merge_all` + `voting`
|
||||
4. **Medical/Research**: Use `credibility_weighted` for authoritative sources
|
||||
5. **Fraud/Security**: Use `graph_based` + `logical` + `expert_review`
|
||||
6. **Exact Matching Required**: Use `exact` strategy (IOCs, identifiers)
|
||||
|
||||
@@ -0,0 +1,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).
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>semantica-explorer</title>
|
||||
<title>Semantica Knowledge Explorer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "semantica-explorer",
|
||||
"name": "semantica-knowledge-explorer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs"
|
||||
"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",
|
||||
@@ -22,6 +23,7 @@
|
||||
"graphology-metrics": "^2.4.0",
|
||||
"graphology-shortest-path": "^2.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"playwright": "^1.59.1",
|
||||
"react": "^19.2.4",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -33,19 +35,19 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@rolldown/plugin-babel": "^0.2.1",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@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": "^8.0.1"
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -1,6 +1,6 @@
|
||||
/* ── 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');
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@500;600;700;800&display=swap');
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
@@ -12,7 +12,7 @@ html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'IBM Plex Sans', 'Space Grotesk', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #0d1117;
|
||||
@@ -63,3 +63,9 @@ code, pre, .mono {
|
||||
.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; }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
GraphArrowVisibilityPolicy,
|
||||
GraphBadgeKind,
|
||||
GraphEdgeVariant,
|
||||
GraphEntityShapeVariant,
|
||||
GraphLabelVisibilityPolicy,
|
||||
GraphNodeShapeVariant,
|
||||
} from "../workspaces/GraphWorkspace/graphTheme";
|
||||
@@ -36,12 +37,17 @@ export interface NodeAttributes {
|
||||
borderSize?: number;
|
||||
nodeVariant?: GraphNodeShapeVariant;
|
||||
nodeShapeVariant?: GraphNodeShapeVariant;
|
||||
entityShape?: GraphEntityShapeVariant;
|
||||
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;
|
||||
@@ -74,6 +80,12 @@ export interface EdgeAttributes {
|
||||
parallelIndex?: number;
|
||||
parallelCount?: number;
|
||||
familySize?: number;
|
||||
rawEdgeIds?: string[];
|
||||
isAggregated?: boolean;
|
||||
aggregateCount?: number;
|
||||
dominantEdgeType?: string;
|
||||
representativeWeight?: number;
|
||||
bundleKind?: "parallel" | "bidirectional" | "community";
|
||||
|
||||
|
||||
edgeType: string;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
@@ -29,6 +30,11 @@ export function DiffMergeWorkspace() {
|
||||
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...");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,746 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, withAlpha } 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";
|
||||
// FR-1 distance intelligence enrichment
|
||||
semantic_similarity?: number | null;
|
||||
path_coherence_score?: number | null;
|
||||
confidence_decay?: number | null;
|
||||
bottleneck_node?: string | null;
|
||||
alternative_path_count?: number;
|
||||
interpretation?: string;
|
||||
};
|
||||
|
||||
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 Distance Intelligence Panel ──────────────────────────── */
|
||||
|
||||
const BAND_COLORS: Record<string, string> = {
|
||||
direct: "#3fb950",
|
||||
near: "#79c0ff",
|
||||
"mid-range": "#e3b341",
|
||||
distant: "#ff7b72",
|
||||
};
|
||||
|
||||
function PathDistanceIntelPanel({ result }: { result: PathResponse }) {
|
||||
const bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e";
|
||||
const hasMetrics =
|
||||
result.confidence_decay != null ||
|
||||
result.semantic_similarity != null ||
|
||||
result.path_coherence_score != null ||
|
||||
result.bottleneck_node != null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||
{/* distance band + alt paths */}
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<span
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: withAlpha(bandColor, 0.14),
|
||||
border: `1px solid ${withAlpha(bandColor, 0.3)}`,
|
||||
color: bandColor,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{result.distance_band} · {result.hop_count} hop{result.hop_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{(result.alternative_path_count ?? 0) > 0 && (
|
||||
<span style={subtleChipStyle}>{result.alternative_path_count} alt path{result.alternative_path_count !== 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* metric grid */}
|
||||
{hasMetrics && <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{result.confidence_decay != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Confidence Decay</div>
|
||||
<div
|
||||
style={{
|
||||
...metricValueStyle,
|
||||
color: result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||
}}
|
||||
>
|
||||
{(result.confidence_decay * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={metricBarTrackStyle}>
|
||||
<div
|
||||
style={{
|
||||
...metricBarFillStyle,
|
||||
width: `${result.confidence_decay * 100}%`,
|
||||
background:
|
||||
result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.semantic_similarity != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Semantic Sim.</div>
|
||||
<div style={{ ...metricValueStyle, color: "#79c0ff" }}>
|
||||
{(result.semantic_similarity * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={metricBarTrackStyle}>
|
||||
<div style={{ ...metricBarFillStyle, width: `${result.semantic_similarity * 100}%`, background: "#79c0ff" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.path_coherence_score != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Path Coherence</div>
|
||||
<div style={{ ...metricValueStyle, color: "#a5d6a7" }}>
|
||||
{(result.path_coherence_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.bottleneck_node && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Bottleneck</div>
|
||||
<div
|
||||
style={{
|
||||
...metricValueStyle,
|
||||
color: "#e3b341",
|
||||
fontSize: 11,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={result.bottleneck_node}
|
||||
>
|
||||
{getNodeLabel(result.bottleneck_node)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* interpretation */}
|
||||
{result.interpretation && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
background: "rgba(88,166,255,0.06)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(88,166,255,0.14)",
|
||||
color: "#a0b4cc",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{result.interpretation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 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,
|
||||
bottleneckNodeId,
|
||||
onFocusNode,
|
||||
}: {
|
||||
path: string[];
|
||||
edgeIds?: string[];
|
||||
totalWeight: number;
|
||||
bottleneckNodeId?: string | null;
|
||||
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={nodeId === bottleneckNodeId ? `Bottleneck: ${nodeId}` : `Focus: ${nodeId}`}
|
||||
style={{
|
||||
...pathNodeChipStyle,
|
||||
cursor: onFocusNode ? "pointer" : "default",
|
||||
...(nodeId === bottleneckNodeId
|
||||
? { border: "1px solid rgba(227,179,65,0.5)", background: "rgba(227,179,65,0.12)" }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<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(98, 226, 205, 0.07)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", background: GRAPH_THEME.ui.timeline.playheadSoft }} />
|
||||
</div>
|
||||
<p style={{ color: GRAPH_THEME.ui.text.muted, 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 ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: GRAPH_THEME.ui.timeline.playhead, boxShadow: "0 0 10px rgba(98, 226, 205, 0.34)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{nodeId}
|
||||
</h3>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
</div>
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, 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 ${GRAPH_THEME.ui.surface.divider}`, 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: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? effectiveNodeId)}
|
||||
</h3>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{groupedDisplaySelection ? nodeId : effectiveNodeId}
|
||||
</div>
|
||||
{groupedDisplaySelection ? (
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, 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(233, 196, 122, 0.075)", border: "1px solid rgba(233, 196, 122, 0.22)", borderRadius: 8, fontSize: 12, color: GRAPH_THEME.palette.accent.selected, 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}
|
||||
bottleneckNodeId={pathResult.bottleneck_node}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
<PathDistanceIntelPanel result={pathResult} />
|
||||
</>
|
||||
) : (
|
||||
<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: GRAPH_THEME.ui.text.strong, fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12 }}>{prediction.type}</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
}}>
|
||||
{(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: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, 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: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, 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: GRAPH_THEME.ui.control.inputBg,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.inputBorder}`,
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
borderRadius: 12,
|
||||
padding: "11px 13px",
|
||||
fontSize: 13,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.035)",
|
||||
};
|
||||
|
||||
const groupedSelectionNoticeStyle: CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "10px 12px",
|
||||
background: "rgba(98, 226, 205, 0.07)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
borderRadius: 12,
|
||||
};
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
background: GRAPH_THEME.ui.control.primaryBg,
|
||||
color: GRAPH_THEME.ui.control.primaryText,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
|
||||
borderRadius: 12,
|
||||
padding: "9px 12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), 0 10px 24px rgba(0,0,0,0.18)",
|
||||
};
|
||||
|
||||
const secondaryActionButtonStyle: CSSProperties = {
|
||||
...actionButtonStyle,
|
||||
background: GRAPH_THEME.ui.control.defaultBg,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.defaultBorder}`,
|
||||
color: GRAPH_THEME.ui.control.defaultText,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const predictionCardStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
background: "rgba(255, 255, 255, 0.035)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const propertyCardStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.028)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const subtleChipStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
color: GRAPH_THEME.ui.text.body,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
background: GRAPH_THEME.ui.surface.cardSubtle,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
borderRadius: 14,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
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(98, 226, 205, 0.08)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
maxWidth: 160,
|
||||
};
|
||||
|
||||
const pathNodeIndexStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
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: GRAPH_THEME.ui.text.subtle,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
maxWidth: 70,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const metricCardStyle: CSSProperties = {
|
||||
background: "rgba(0,0,0,0.18)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 10px",
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
};
|
||||
|
||||
const metricLabelStyle: CSSProperties = {
|
||||
color: "rgba(88,166,255,0.65)",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const metricValueStyle: CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "#e6edf3",
|
||||
};
|
||||
|
||||
const metricBarTrackStyle: CSSProperties = {
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.07)",
|
||||
overflow: "hidden",
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const metricBarFillStyle: CSSProperties = {
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
transition: "width 300ms ease",
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
|
||||
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,
|
||||
@@ -41,6 +42,7 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
|
||||
lensMode: "neighborhood",
|
||||
effectQuality: "bounded",
|
||||
};
|
||||
const EMPTY_PATH: string[] = [];
|
||||
|
||||
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
|
||||
|
||||
@@ -67,6 +69,10 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +119,10 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
||||
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]);
|
||||
|
||||
@@ -447,9 +457,16 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
||||
<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}
|
||||
@@ -33,6 +33,8 @@ type LinkPrediction = {
|
||||
type PathResponse = {
|
||||
path: GraphPath;
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
};
|
||||
|
||||
type TemporalBounds = {
|
||||
@@ -137,6 +139,10 @@ function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor
|
||||
valid_until: node.valid_until ?? null,
|
||||
properties: node.properties ?? {},
|
||||
neighborCount,
|
||||
visibleNeighborCount: neighborCount,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: neighborCount > 8,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,6 +431,10 @@ export function GraphWorkspaceShell() {
|
||||
valid_until: null,
|
||||
properties: searchNode.properties ?? {},
|
||||
neighborCount: 0,
|
||||
visibleNeighborCount: 0,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: false,
|
||||
}
|
||||
: null;
|
||||
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
|
||||
@@ -500,8 +510,13 @@ export function GraphWorkspaceShell() {
|
||||
if (!selectedNodeId || !pathTargetId.trim()) return;
|
||||
|
||||
try {
|
||||
const pathParams = new URLSearchParams({
|
||||
source: selectedNodeId,
|
||||
target: pathTargetId.trim(),
|
||||
algorithm: "dijkstra",
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`,
|
||||
`/api/graph/path?${pathParams.toString()}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||
@@ -553,6 +568,19 @@ export function GraphWorkspaceShell() {
|
||||
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
|
||||
@@ -629,8 +657,8 @@ export function GraphWorkspaceShell() {
|
||||
<div className="graph-toggle-cluster">
|
||||
{selectedNodeId ? (
|
||||
<>
|
||||
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
||||
<button onClick={() => setViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
||||
<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>
|
||||
@@ -24,6 +24,8 @@ export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
|
||||
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) => {
|
||||
@@ -3,6 +3,7 @@ import { DataSet } from "vis-data";
|
||||
import { Timeline } from "vis-timeline";
|
||||
import type { TimelineOptions } from "vis-timeline";
|
||||
import "vis-timeline/styles/vis-timeline-graph2d.css";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
|
||||
export interface TimelinePanelProps {
|
||||
onTimeChange: (time: Date) => void;
|
||||
@@ -19,35 +20,35 @@ 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-panel { border-color: ${GRAPH_THEME.ui.timeline.border} !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text {
|
||||
color: #8b949e !important;
|
||||
color: ${GRAPH_THEME.ui.timeline.text} !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;
|
||||
color: ${GRAPH_THEME.ui.timeline.textStrong} !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-time-axis .vis-grid.vis-minor { border-color: ${GRAPH_THEME.ui.timeline.gridMinor} !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: ${GRAPH_THEME.ui.timeline.gridMajor} !important; }
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} {
|
||||
background: rgba(88, 166, 255, 0.15) !important;
|
||||
background: ${GRAPH_THEME.ui.timeline.playheadSoft} !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;
|
||||
background: ${GRAPH_THEME.ui.timeline.playhead} !important;
|
||||
color: ${GRAPH_THEME.ui.text.inverse} !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;
|
||||
box-shadow: 0 0 8px rgba(98, 226, 205, 0.45) !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-current-time { display: none !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
|
||||
@@ -166,14 +167,14 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
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 }}>
|
||||
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: `1px solid ${GRAPH_THEME.ui.timeline.border}`, background: GRAPH_THEME.ui.timeline.background, 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 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: `1px solid ${GRAPH_THEME.ui.timeline.border}`, 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" }}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? GRAPH_THEME.ui.control.activeBorder : GRAPH_THEME.ui.control.defaultBorder}`, background: isPlaying ? GRAPH_THEME.ui.timeline.playheadSoft : GRAPH_THEME.ui.control.defaultBg, color: GRAPH_THEME.ui.timeline.playhead, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(98, 226, 205, 0.32)" : "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>
|
||||
@@ -181,12 +182,12 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
<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" }}>
|
||||
<span style={{ fontSize: 10, color: isPlaying ? GRAPH_THEME.ui.timeline.playhead : GRAPH_THEME.ui.timeline.text, 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 }}>
|
||||
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: GRAPH_THEME.ui.text.subtle, textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
||||
Temporal Scrubber · {minBound.getFullYear()}-{maxBound.getFullYear()}
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,19 @@ export const clickSelectionBehavior: GraphBehavior = {
|
||||
onNodeClick: (context, nodeId) => {
|
||||
context.setHoveredNodeId(nodeId);
|
||||
context.onEdgeSelectionChange("");
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
if (context.getInteractionState().selectedNodeId === nodeId) {
|
||||
context.onNodeSelectionChange("");
|
||||
} else {
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
}
|
||||
},
|
||||
onEdgeClick: (context, edgeId) => {
|
||||
context.setHoveredNodeId(null);
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
if (context.getInteractionState().selectedEdgeId === edgeId) {
|
||||
context.onEdgeSelectionChange("");
|
||||
} else {
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
}
|
||||
},
|
||||
onStageClick: (context) => {
|
||||
context.setHoveredNodeId(null);
|
||||
@@ -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,52 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
const SWEEP_TICKS = 6;
|
||||
const SWEEP_INTERVAL_MS = 60;
|
||||
|
||||
export function createPathHighlightBehavior(): GraphBehavior {
|
||||
let lastPathSignature = "";
|
||||
let sweepTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sweepGeneration = 0;
|
||||
|
||||
function cancelSweep() {
|
||||
sweepGeneration++;
|
||||
if (sweepTimer !== null) {
|
||||
clearTimeout(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSweep(sigma: { refresh: () => void }, tick: number, gen: number) {
|
||||
if (tick >= SWEEP_TICKS) return;
|
||||
sweepTimer = setTimeout(() => {
|
||||
if (gen !== sweepGeneration) return;
|
||||
sigma.refresh();
|
||||
scheduleSweep(sigma, tick + 1, gen);
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
id: "path-highlight",
|
||||
attach: () => {},
|
||||
detach: (context) => {
|
||||
cancelSweep();
|
||||
lastPathSignature = "";
|
||||
context.sigma.refresh();
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextPathSignature = interactionState.activePath.join("::");
|
||||
if (nextPathSignature === lastPathSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPathSignature = nextPathSignature;
|
||||
cancelSweep();
|
||||
context.sigma.refresh();
|
||||
|
||||
// Animate intermediate nodes lighting up sequentially
|
||||
if (interactionState.activePath.length > 2) {
|
||||
scheduleSweep(context.sigma, 0, sweepGeneration);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
|
||||
|
||||
export type GraphBehaviorActionRequest =
|
||||
| { type: "fitView" }
|
||||
| { type: "focusNode"; nodeId: string };
|
||||
| { type: "focusNode"; nodeId: string }
|
||||
| { type: "centerSelection"; nodeId: string }
|
||||
| { type: "centerGroupedSelection"; nodeId: string };
|
||||
|
||||
export interface GraphBehaviorContext {
|
||||
sigma: Sigma;
|
||||
@@ -17,6 +19,8 @@ export interface GraphBehaviorContext {
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
import type { GraphViewMode } from "../types";
|
||||
|
||||
export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
let lastViewMode: "focused" | "full" | null = null;
|
||||
let lastViewMode: GraphViewMode | null = null;
|
||||
|
||||
return {
|
||||
id: "view-mode-switch",
|
||||
@@ -15,9 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
}
|
||||
|
||||
lastViewMode = interactionState.viewMode;
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
|
||||
if (interactionState.focusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
|
||||
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,8 +44,18 @@ 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;
|
||||
const MAX_BACKBONE_CENTRAL_LINKS = 36;
|
||||
const MAX_BACKBONE_BRIDGES = 80;
|
||||
const MAX_BACKBONE_TOTAL_EDGES = 128;
|
||||
const MAX_BACKBONE_EDGES_PER_NODE = 5;
|
||||
const MAX_BACKBONE_PARALLEL_PAIR_EDGES = 2;
|
||||
|
||||
type BackboneCandidate = {
|
||||
edgeId: string;
|
||||
source: string;
|
||||
target: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
function getNodeLabel(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
@@ -364,6 +374,61 @@ function scoreBackboneEdge(
|
||||
return weight * 1.4 + (sourceScore + targetScore) * 2.4 + priority * 0.6 + parallelBoost + bidirectionalBoost;
|
||||
}
|
||||
|
||||
function upsertBackboneCandidate(
|
||||
candidates: Map<string, BackboneCandidate>,
|
||||
key: string,
|
||||
candidate: BackboneCandidate,
|
||||
) {
|
||||
const current = candidates.get(key);
|
||||
if (
|
||||
!current
|
||||
|| candidate.score > current.score
|
||||
|| (candidate.score === current.score && candidate.edgeId.localeCompare(current.edgeId) < 0)
|
||||
) {
|
||||
candidates.set(key, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function addRankedBackboneCandidates(
|
||||
selected: BackboneCandidate[],
|
||||
selectedEdgeIds: Set<string>,
|
||||
nodeUseCounts: Map<string, number>,
|
||||
pairUseCounts: Map<string, number>,
|
||||
candidates: Iterable<BackboneCandidate>,
|
||||
maxToAdd: number,
|
||||
) {
|
||||
const ranked = [...candidates].sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
|
||||
for (const candidate of ranked) {
|
||||
if (selected.length >= MAX_BACKBONE_TOTAL_EDGES || maxToAdd <= 0 || selectedEdgeIds.has(candidate.edgeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pairKey = [candidate.source, candidate.target].sort().join("::");
|
||||
if ((nodeUseCounts.get(candidate.source) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
|
||||
continue;
|
||||
}
|
||||
if ((nodeUseCounts.get(candidate.target) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
|
||||
continue;
|
||||
}
|
||||
if ((pairUseCounts.get(pairKey) ?? 0) >= MAX_BACKBONE_PARALLEL_PAIR_EDGES) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(candidate);
|
||||
selectedEdgeIds.add(candidate.edgeId);
|
||||
nodeUseCounts.set(candidate.source, (nodeUseCounts.get(candidate.source) ?? 0) + 1);
|
||||
nodeUseCounts.set(candidate.target, (nodeUseCounts.get(candidate.target) ?? 0) + 1);
|
||||
pairUseCounts.set(pairKey, (pairUseCounts.get(pairKey) ?? 0) + 1);
|
||||
maxToAdd -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function buildOverviewBackboneSnapshot(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
@@ -379,7 +444,10 @@ function buildOverviewBackboneSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
const selected: BackboneCandidate[] = [];
|
||||
const selectedEdgeIds = new Set<string>();
|
||||
const nodeUseCounts = new Map<string, number>();
|
||||
const pairUseCounts = new Map<string, number>();
|
||||
const regionByNode = new Map<string, string>();
|
||||
visibleNodeIds.forEach((nodeId) => {
|
||||
regionByNode.set(nodeId, getNodeSemanticGroup(graphRef, nodeId));
|
||||
@@ -397,7 +465,7 @@ function buildOverviewBackboneSnapshot(
|
||||
.map((summary) => summary.id)
|
||||
.filter((nodeId) => visibleNodeIds.has(nodeId));
|
||||
|
||||
const coreLinkCandidates = new Map<string, { edgeId: string; score: number }>();
|
||||
const coreLinkCandidates = new Map<string, BackboneCandidate>();
|
||||
anchorIds.forEach((anchorId) => {
|
||||
collectNodeIncidentEdges(graphRef, anchorId, visibleNodeIds)
|
||||
.filter((entry) => {
|
||||
@@ -415,60 +483,88 @@ function buildOverviewBackboneSnapshot(
|
||||
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 });
|
||||
}
|
||||
upsertBackboneCandidate(coreLinkCandidates, pairKey, {
|
||||
edgeId: entry.edgeId,
|
||||
source: entry.source,
|
||||
target: entry.target,
|
||||
score,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const bridgeByPair = new Map<string, { edgeId: string; score: number }>();
|
||||
const bridgeCandidates = new Map<string, BackboneCandidate>();
|
||||
const structuralCandidates = new Map<string, BackboneCandidate>();
|
||||
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;
|
||||
const edgeKey = String(edgeId);
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
const sourceRegion = regionByNode.get(sourceId);
|
||||
const targetRegion = regionByNode.get(targetId);
|
||||
const sourceCommunity = base.communitiesByNode.get(sourceId);
|
||||
const targetCommunity = base.communitiesByNode.get(targetId);
|
||||
const crossesSemanticRegion = Boolean(sourceRegion && targetRegion && sourceRegion !== targetRegion);
|
||||
const crossesCommunity = sourceCommunity !== undefined && targetCommunity !== undefined && sourceCommunity !== targetCommunity;
|
||||
const sourceCentrality = base.centralityByNode.get(sourceId)?.score ?? 0;
|
||||
const targetCentrality = base.centralityByNode.get(targetId)?.score ?? 0;
|
||||
|
||||
const baseScore = scoreBackboneEdge(attrs as EdgeAttributes, sourceId, targetId, base);
|
||||
const semanticBoost = crossesSemanticRegion ? 0.5 : 0;
|
||||
const communityBoost = crossesCommunity ? 0.36 : 0;
|
||||
const topRegionBoost = sourceRegion && targetRegion && (topRegionIds.has(sourceRegion) || topRegionIds.has(targetRegion)) ? 0.32 : 0;
|
||||
const centralityBalance = Math.min(sourceCentrality, targetCentrality) * 1.2;
|
||||
const score = baseScore + semanticBoost + communityBoost + topRegionBoost + centralityBalance;
|
||||
const candidate = {
|
||||
edgeId: edgeKey,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
score,
|
||||
};
|
||||
|
||||
if (crossesSemanticRegion || crossesCommunity) {
|
||||
const bridgeKey = [
|
||||
sourceRegion ?? `community:${sourceCommunity ?? sourceId}`,
|
||||
targetRegion ?? `community:${targetCommunity ?? targetId}`,
|
||||
Math.min(sourceCentrality, targetCentrality).toFixed(4),
|
||||
].sort().join("::");
|
||||
upsertBackboneCandidate(bridgeCandidates, bridgeKey, candidate);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
const pairKey = [sourceId, targetId].sort().join("::");
|
||||
upsertBackboneCandidate(structuralCandidates, pairKey, candidate);
|
||||
});
|
||||
|
||||
[...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));
|
||||
addRankedBackboneCandidates(
|
||||
selected,
|
||||
selectedEdgeIds,
|
||||
nodeUseCounts,
|
||||
pairUseCounts,
|
||||
bridgeCandidates.values(),
|
||||
MAX_BACKBONE_BRIDGES,
|
||||
);
|
||||
addRankedBackboneCandidates(
|
||||
selected,
|
||||
selectedEdgeIds,
|
||||
nodeUseCounts,
|
||||
pairUseCounts,
|
||||
coreLinkCandidates.values(),
|
||||
MAX_BACKBONE_CENTRAL_LINKS,
|
||||
);
|
||||
addRankedBackboneCandidates(
|
||||
selected,
|
||||
selectedEdgeIds,
|
||||
nodeUseCounts,
|
||||
pairUseCounts,
|
||||
structuralCandidates.values(),
|
||||
MAX_BACKBONE_TOTAL_EDGES - selected.length,
|
||||
);
|
||||
|
||||
[...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));
|
||||
const edgeIds = selected
|
||||
.map((entry) => entry.edgeId)
|
||||
.filter((edgeId) => graphRef.hasEdge(edgeId));
|
||||
|
||||
return {
|
||||
ready: edgeIds.length > 0,
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { GraphEntityShapeVariant } from "./graphTheme";
|
||||
|
||||
export const ENTITY_SHAPE_ALIASES: Array<[GraphEntityShapeVariant, RegExp]> = [
|
||||
["biomolecule", /\b(gene|protein|enzyme|receptor|target|transcript|rna|dna|mirna|biomolecule|peptide)\b/i],
|
||||
["condition", /\b(disease|condition|phenotype|symptom|disorder|syndrome|diagnosis|pathology|trait)\b/i],
|
||||
["compound", /\b(drug|chemical|compound|metabolite|molecule|small[_\s-]?molecule|ligand|therapeutic|medication|substance)\b/i],
|
||||
["process", /\b(pathway|process|mechanism|function|ontology|biological[_\s-]?process|cellular[_\s-]?process|program|module)\b/i],
|
||||
];
|
||||
|
||||
export function classifyEntityShape(
|
||||
nodeType?: string,
|
||||
semanticGroup?: string,
|
||||
content?: string,
|
||||
properties?: Record<string, unknown>,
|
||||
): GraphEntityShapeVariant {
|
||||
const values = [
|
||||
nodeType,
|
||||
semanticGroup,
|
||||
content,
|
||||
String(properties?.type ?? ""),
|
||||
String(properties?.category ?? ""),
|
||||
String(properties?.label ?? ""),
|
||||
]
|
||||
.filter((value) => typeof value === "string" && value.trim().length > 0)
|
||||
.join(" ");
|
||||
|
||||
for (const [shape, pattern] of ENTITY_SHAPE_ALIASES) {
|
||||
if (pattern.test(values)) {
|
||||
return shape;
|
||||
}
|
||||
}
|
||||
|
||||
return "entity";
|
||||
}
|
||||
@@ -266,8 +266,8 @@ function renderDensityField(
|
||||
(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(0, "rgba(255,255,255,0.05)");
|
||||
gradient.addColorStop(0.58, "rgba(255,255,255,0.02)");
|
||||
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
@@ -0,0 +1,335 @@
|
||||
import type Graph from "graphology";
|
||||
import type Sigma from "sigma";
|
||||
|
||||
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type {
|
||||
GraphFullEdgeClass,
|
||||
GraphFullEdgeClassDiagnostics,
|
||||
GraphInteractionState,
|
||||
GraphStructureLayerDiagnostics,
|
||||
GraphStructureLayerDisabledReason,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
type GraphRef = Graph;
|
||||
|
||||
type StructureLayerMode = typeof GRAPH_THEME.edges.fullGraphStructureLayer.mode;
|
||||
|
||||
export type GraphStructureCurve = {
|
||||
edgeId: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
source: { x: number; y: number };
|
||||
target: { x: number; y: number };
|
||||
edgeClass: Extract<GraphFullEdgeClass, "backbone" | "bridge">;
|
||||
priority: number;
|
||||
curvature: number;
|
||||
};
|
||||
|
||||
export type GraphStructureCurveCache = {
|
||||
cacheKey: string;
|
||||
curves: GraphStructureCurve[];
|
||||
bridgeCurveCount: number;
|
||||
backboneCurveCount: number;
|
||||
};
|
||||
|
||||
export type GraphStructureLayerGateInput = {
|
||||
mode: StructureLayerMode;
|
||||
viewMode: GraphViewMode;
|
||||
isLayoutRunning: boolean;
|
||||
edgeDiagnostics?: GraphFullEdgeClassDiagnostics;
|
||||
minimumLiteralEdges: number;
|
||||
};
|
||||
|
||||
export type GraphStructureLayerGate = {
|
||||
enabled: boolean;
|
||||
disabledReason: GraphStructureLayerDisabledReason | null;
|
||||
};
|
||||
|
||||
export function evaluateGraphStructureLayerGate({
|
||||
mode,
|
||||
viewMode,
|
||||
isLayoutRunning,
|
||||
edgeDiagnostics,
|
||||
minimumLiteralEdges,
|
||||
}: GraphStructureLayerGateInput): GraphStructureLayerGate {
|
||||
if (mode === "off") {
|
||||
return { enabled: false, disabledReason: "disabled" };
|
||||
}
|
||||
|
||||
if (viewMode !== "full") {
|
||||
return { enabled: false, disabledReason: "non-full-mode" };
|
||||
}
|
||||
|
||||
if (isLayoutRunning) {
|
||||
return { enabled: false, disabledReason: "layout-running" };
|
||||
}
|
||||
|
||||
if (mode === "auto") {
|
||||
const literalEdges = (edgeDiagnostics?.counts.backbone ?? 0) + (edgeDiagnostics?.counts.bridge ?? 0);
|
||||
if (literalEdges >= minimumLiteralEdges) {
|
||||
return { enabled: false, disabledReason: "enough-literal-edges" };
|
||||
}
|
||||
}
|
||||
|
||||
return { enabled: true, disabledReason: null };
|
||||
}
|
||||
|
||||
function isFinitePoint(attrs: NodeAttributes) {
|
||||
return Number.isFinite(Number(attrs.x)) && Number.isFinite(Number(attrs.y));
|
||||
}
|
||||
|
||||
function getEdgePriority(attrs: EdgeAttributes) {
|
||||
return Math.max(0, Math.min(1, Number(attrs.visualPriority ?? attrs.weight ?? 0)));
|
||||
}
|
||||
|
||||
function getCurveSortRank(edgeClass: GraphFullEdgeClass, priority: number) {
|
||||
return (edgeClass === "bridge" ? 2 : 1) + priority;
|
||||
}
|
||||
|
||||
function getDeterministicCurveSign(sourceId: string, targetId: string, edgeId: string) {
|
||||
const seed = `${sourceId}|${targetId}|${edgeId}`;
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 31 + seed.charCodeAt(index)) | 0;
|
||||
}
|
||||
return hash % 2 === 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
export function createGraphStructureCacheKey({
|
||||
graphVersion,
|
||||
zoomTier,
|
||||
layoutSettledEpoch,
|
||||
overviewBackboneEdgeIds,
|
||||
}: {
|
||||
graphVersion: number;
|
||||
zoomTier: GraphInteractionState["zoomTier"];
|
||||
layoutSettledEpoch: number;
|
||||
overviewBackboneEdgeIds: Set<string>;
|
||||
}) {
|
||||
return [
|
||||
graphVersion,
|
||||
zoomTier,
|
||||
layoutSettledEpoch,
|
||||
Array.from(overviewBackboneEdgeIds).sort().join(","),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function buildGraphStructureCurveCache({
|
||||
graphRef,
|
||||
cacheKey,
|
||||
classifyEdge,
|
||||
maxCurves,
|
||||
curveStrength,
|
||||
}: {
|
||||
graphRef: GraphRef;
|
||||
cacheKey: string;
|
||||
classifyEdge: (edgeId: string) => GraphFullEdgeClass;
|
||||
maxCurves: number;
|
||||
curveStrength: number;
|
||||
}): GraphStructureCurveCache {
|
||||
const candidates: Array<GraphStructureCurve & { rank: number }> = [];
|
||||
|
||||
graphRef.forEachEdge((edgeId, attrs, source, target) => {
|
||||
const stableEdgeId = String(edgeId);
|
||||
const edgeClass = classifyEdge(stableEdgeId);
|
||||
if (edgeClass !== "bridge" && edgeClass !== "backbone") {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
if (!graphRef.hasNode(sourceId) || !graphRef.hasNode(targetId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceAttrs = graphRef.getNodeAttributes(sourceId) as NodeAttributes;
|
||||
const targetAttrs = graphRef.getNodeAttributes(targetId) as NodeAttributes;
|
||||
if (!isFinitePoint(sourceAttrs) || !isFinitePoint(targetAttrs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const priority = getEdgePriority(attrs as EdgeAttributes);
|
||||
candidates.push({
|
||||
edgeId: stableEdgeId,
|
||||
sourceId,
|
||||
targetId,
|
||||
source: { x: Number(sourceAttrs.x), y: Number(sourceAttrs.y) },
|
||||
target: { x: Number(targetAttrs.x), y: Number(targetAttrs.y) },
|
||||
edgeClass,
|
||||
priority,
|
||||
curvature: getDeterministicCurveSign(sourceId, targetId, stableEdgeId) * curveStrength,
|
||||
rank: getCurveSortRank(edgeClass, priority),
|
||||
});
|
||||
});
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (right.rank !== left.rank) {
|
||||
return right.rank - left.rank;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
|
||||
const curves = candidates.slice(0, maxCurves).map(({ rank: _rank, ...curve }) => curve);
|
||||
return {
|
||||
cacheKey,
|
||||
curves,
|
||||
bridgeCurveCount: curves.filter((curve) => curve.edgeClass === "bridge").length,
|
||||
backboneCurveCount: curves.filter((curve) => curve.edgeClass === "backbone").length,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGraphStructureLayerDiagnostics({
|
||||
gate,
|
||||
cache,
|
||||
minimumCurves,
|
||||
canvasAvailable,
|
||||
lastDrawAt,
|
||||
}: {
|
||||
gate: GraphStructureLayerGate;
|
||||
cache: GraphStructureCurveCache | null;
|
||||
minimumCurves: number;
|
||||
canvasAvailable: boolean;
|
||||
lastDrawAt: number | null;
|
||||
}): GraphStructureLayerDiagnostics {
|
||||
if (!gate.enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: gate.disabledReason,
|
||||
curveCount: 0,
|
||||
bridgeCurveCount: 0,
|
||||
backboneCurveCount: 0,
|
||||
cacheKey: cache?.cacheKey ?? "",
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!canvasAvailable) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: "invalid-layer",
|
||||
curveCount: 0,
|
||||
bridgeCurveCount: 0,
|
||||
backboneCurveCount: 0,
|
||||
cacheKey: cache?.cacheKey ?? "",
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!cache || cache.curves.length === 0) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: "no-eligible-edges",
|
||||
curveCount: 0,
|
||||
bridgeCurveCount: 0,
|
||||
backboneCurveCount: 0,
|
||||
cacheKey: cache?.cacheKey ?? "",
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (cache.curves.length < minimumCurves) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: "cache-empty",
|
||||
curveCount: cache.curves.length,
|
||||
bridgeCurveCount: cache.bridgeCurveCount,
|
||||
backboneCurveCount: cache.backboneCurveCount,
|
||||
cacheKey: cache.cacheKey,
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
curveCount: cache.curves.length,
|
||||
bridgeCurveCount: cache.bridgeCurveCount,
|
||||
backboneCurveCount: cache.backboneCurveCount,
|
||||
cacheKey: cache.cacheKey,
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearGraphStructureLayer(canvas: HTMLCanvasElement | null) {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
export function drawGraphStructureLayer({
|
||||
sigma,
|
||||
canvas,
|
||||
cache,
|
||||
}: {
|
||||
sigma: Sigma;
|
||||
canvas: HTMLCanvasElement;
|
||||
cache: GraphStructureCurveCache;
|
||||
}) {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { width, height } = sigma.getDimensions();
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
|
||||
let drawn = 0;
|
||||
for (const curve of cache.curves) {
|
||||
const sourceData = sigma.getNodeDisplayData(curve.sourceId);
|
||||
const targetData = sigma.getNodeDisplayData(curve.targetId);
|
||||
if (!sourceData || !targetData || sourceData.hidden || targetData.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePoint = sigma.graphToViewport(curve.source);
|
||||
const targetPoint = sigma.graphToViewport(curve.target);
|
||||
if (
|
||||
!Number.isFinite(sourcePoint.x)
|
||||
|| !Number.isFinite(sourcePoint.y)
|
||||
|| !Number.isFinite(targetPoint.x)
|
||||
|| !Number.isFinite(targetPoint.y)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dx = targetPoint.x - sourcePoint.x;
|
||||
const dy = targetPoint.y - sourcePoint.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (distance <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nx = -dy / distance;
|
||||
const ny = dx / distance;
|
||||
const offset = distance * curve.curvature;
|
||||
const controlX = (sourcePoint.x + targetPoint.x) / 2 + nx * offset;
|
||||
const controlY = (sourcePoint.y + targetPoint.y) / 2 + ny * offset;
|
||||
const layerTheme = GRAPH_THEME.edges.fullGraphStructureLayer;
|
||||
|
||||
context.beginPath();
|
||||
context.strokeStyle = curve.edgeClass === "bridge"
|
||||
? withAlpha(GRAPH_THEME.palette.muted.edgeFocus, layerTheme.bridgeAlpha)
|
||||
: withAlpha(GRAPH_THEME.palette.muted.edgeStructure, layerTheme.backboneAlpha);
|
||||
context.lineWidth = curve.edgeClass === "bridge"
|
||||
? layerTheme.bridgeLineWidth
|
||||
: layerTheme.backboneLineWidth;
|
||||
context.moveTo(sourcePoint.x, sourcePoint.y);
|
||||
context.quadraticCurveTo(controlX, controlY, targetPoint.x, targetPoint.y);
|
||||
context.stroke();
|
||||
drawn += 1;
|
||||
}
|
||||
|
||||
return drawn > 0;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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 GraphEntityShapeVariant = "entity" | "biomolecule" | "condition" | "compound" | "process" | "community";
|
||||
export type GraphEdgeVariant = "line" | "directional" | "bidirectionalCurve" | "parallelCurve" | "pathSignal";
|
||||
export type GraphArrowVisibilityPolicy = "hidden" | "contextual" | "always";
|
||||
export type GraphLabelVisibilityPolicy = "none" | "priority" | "local" | "always";
|
||||
@@ -9,6 +10,7 @@ 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: {
|
||||
@@ -52,6 +54,60 @@ export interface GraphTheme {
|
||||
nodeBorder: string;
|
||||
};
|
||||
};
|
||||
ui: {
|
||||
text: {
|
||||
strong: string;
|
||||
body: string;
|
||||
muted: string;
|
||||
subtle: string;
|
||||
inverse: string;
|
||||
};
|
||||
surface: {
|
||||
app: string;
|
||||
stage: string;
|
||||
card: string;
|
||||
cardSubtle: string;
|
||||
cardStrong: string;
|
||||
panel: string;
|
||||
panelBorder: string;
|
||||
divider: string;
|
||||
shadow: string;
|
||||
};
|
||||
scene: {
|
||||
background: string;
|
||||
radialGlow: string;
|
||||
grid: string;
|
||||
gridStrong: string;
|
||||
vignette: string;
|
||||
};
|
||||
control: {
|
||||
defaultBg: string;
|
||||
defaultBorder: string;
|
||||
defaultText: string;
|
||||
hoverBg: string;
|
||||
activeBg: string;
|
||||
activeBorder: string;
|
||||
activeText: string;
|
||||
primaryBg: string;
|
||||
primaryBorder: string;
|
||||
primaryText: string;
|
||||
disabledText: string;
|
||||
inputBg: string;
|
||||
inputBorder: string;
|
||||
focusRing: string;
|
||||
dangerText: string;
|
||||
};
|
||||
timeline: {
|
||||
background: string;
|
||||
border: string;
|
||||
gridMinor: string;
|
||||
gridMajor: string;
|
||||
text: string;
|
||||
textStrong: string;
|
||||
playhead: string;
|
||||
playheadSoft: string;
|
||||
};
|
||||
};
|
||||
zoomTiers: Record<GraphZoomTier, {
|
||||
maxRatio: number;
|
||||
nodeScale: number;
|
||||
@@ -133,6 +189,16 @@ export interface GraphTheme {
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeVisibleFrom: GraphZoomTier;
|
||||
}>;
|
||||
entityShapes: Record<GraphEntityShapeVariant, {
|
||||
label: string;
|
||||
shapeKind: number;
|
||||
aspectRatio: number;
|
||||
fillAlpha: number;
|
||||
shellAlpha: number;
|
||||
coreScale: number;
|
||||
borderBoost: number;
|
||||
minSize: number;
|
||||
}>;
|
||||
selectedRing: {
|
||||
color: string;
|
||||
width: number;
|
||||
@@ -170,6 +236,49 @@ export interface GraphTheme {
|
||||
sizeMultiplier: number;
|
||||
glowAlpha: number;
|
||||
}>;
|
||||
visibility: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, {
|
||||
defaultPriorityThreshold: number;
|
||||
backgroundSampleRate: number;
|
||||
defaultAlpha: number;
|
||||
mutedAlpha: number;
|
||||
inactiveAlpha: number;
|
||||
neighborAlpha: number;
|
||||
sizeMultiplier: number;
|
||||
hideMuted: boolean;
|
||||
}>>;
|
||||
contextCaps: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, number>>;
|
||||
fullGraphStructure: {
|
||||
ambientBackboneAlpha: number;
|
||||
backboneAlpha: number;
|
||||
bridgeAlpha: number;
|
||||
bridgeCurvePriorityThreshold: number;
|
||||
bridgeCurveStrength: number;
|
||||
backboneMaxSize: number;
|
||||
bridgeMaxSize: number;
|
||||
structureEdgeAlpha: number;
|
||||
inspectionEdgeAlpha: number;
|
||||
};
|
||||
fullGraphStructureLayer: {
|
||||
mode: "off" | "auto" | "always";
|
||||
minimumLiteralEdges: number;
|
||||
minimumCurves: number;
|
||||
maxCurves: number;
|
||||
bridgeAlpha: number;
|
||||
backboneAlpha: number;
|
||||
bridgeLineWidth: number;
|
||||
backboneLineWidth: number;
|
||||
curveStrength: number;
|
||||
};
|
||||
};
|
||||
interaction: {
|
||||
localContextAlpha: number;
|
||||
hoverContextAlpha: number;
|
||||
selectedEdgeAlpha: number;
|
||||
pathEdgeAlpha: number;
|
||||
localContextMaxSize: number;
|
||||
selectedEdgeMaxSize: number;
|
||||
pathEdgeMaxSize: number;
|
||||
pathOverlayAlpha: number;
|
||||
};
|
||||
overlays: {
|
||||
hoverGlowAlpha: number;
|
||||
@@ -190,6 +299,35 @@ export interface GraphTheme {
|
||||
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;
|
||||
@@ -265,16 +403,16 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
],
|
||||
overview: {
|
||||
nodeBase: "#0B1320",
|
||||
nodeCore: "#435D7A",
|
||||
nodeCore: "#5A7A9E",
|
||||
nodeMuted: "#121927",
|
||||
nodeBorder: "#64758C",
|
||||
nodeTintMix: 0.03,
|
||||
nodeCoreMix: 0.52,
|
||||
nodeBorder: "#7A92AE",
|
||||
nodeTintMix: 0.14,
|
||||
nodeCoreMix: 0.72,
|
||||
nodeShellAlpha: 0.97,
|
||||
nodeCoreAlpha: 1,
|
||||
edgeBackbone: "rgba(83, 111, 148, 0.04)",
|
||||
edgeStructure: "rgba(72, 90, 118, 0.009)",
|
||||
edgeInspection: "rgba(98, 120, 148, 0.026)",
|
||||
edgeBackbone: "rgba(84, 123, 145, 0.24)",
|
||||
edgeStructure: "rgba(49, 63, 78, 0.08)",
|
||||
edgeInspection: "rgba(76, 102, 128, 0.12)",
|
||||
},
|
||||
accent: {
|
||||
selected: "#F2D288",
|
||||
@@ -285,63 +423,117 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
inferred: "#D07B4D",
|
||||
},
|
||||
muted: {
|
||||
fallback: "rgba(96, 112, 136, 0.1)",
|
||||
nodeAlpha: 0.085,
|
||||
edgeOverview: "rgba(82, 100, 124, 0.009)",
|
||||
edgeStructure: "rgba(92, 112, 138, 0.02)",
|
||||
edgeInspection: "rgba(124, 148, 176, 0.066)",
|
||||
edgeFocus: "rgba(160, 186, 218, 0.16)",
|
||||
fallback: "rgba(96, 112, 136, 0.18)",
|
||||
nodeAlpha: 0.12,
|
||||
edgeOverview: "rgba(32, 45, 55, 0.035)",
|
||||
edgeStructure: "rgba(42, 58, 72, 0.055)",
|
||||
edgeInspection: "rgba(62, 84, 104, 0.075)",
|
||||
edgeFocus: "rgba(132, 178, 202, 0.26)",
|
||||
},
|
||||
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",
|
||||
canvas: "#0A0D11",
|
||||
shell: "rgba(17, 21, 27, 0.82)",
|
||||
shellBorder: "rgba(170, 184, 205, 0.14)",
|
||||
shellGlow: "rgba(0, 0, 0, 0.28)",
|
||||
grid: "rgba(170, 184, 205, 0.026)",
|
||||
vignette: "rgba(3, 4, 7, 0.76)",
|
||||
nodeBorder: "#0B0F15",
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
text: {
|
||||
strong: "#F3F0E8",
|
||||
body: "#D5D9DD",
|
||||
muted: "#9AA3AE",
|
||||
subtle: "#6F7A86",
|
||||
inverse: "#0B0D10",
|
||||
},
|
||||
surface: {
|
||||
app: "#08090B",
|
||||
stage: "#0B0E12",
|
||||
card: "linear-gradient(180deg, rgba(28, 31, 36, 0.88), rgba(16, 18, 23, 0.78))",
|
||||
cardSubtle: "linear-gradient(180deg, rgba(23, 26, 31, 0.72), rgba(13, 15, 19, 0.64))",
|
||||
cardStrong: "linear-gradient(180deg, rgba(34, 37, 43, 0.94), rgba(18, 21, 26, 0.9))",
|
||||
panel: "linear-gradient(180deg, rgba(21, 24, 30, 0.92), rgba(12, 14, 18, 0.9))",
|
||||
panelBorder: "rgba(211, 205, 190, 0.13)",
|
||||
divider: "rgba(211, 205, 190, 0.1)",
|
||||
shadow: "0 22px 60px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.045)",
|
||||
},
|
||||
scene: {
|
||||
background: "linear-gradient(180deg, #0B0E12 0%, #07080B 100%)",
|
||||
radialGlow: "radial-gradient(circle at 50% 18%, rgba(88, 224, 204, 0.07), transparent 30%), radial-gradient(circle at 78% 0%, rgba(217, 168, 92, 0.055), transparent 26%)",
|
||||
grid: "rgba(210, 206, 196, 0.024)",
|
||||
gridStrong: "rgba(210, 206, 196, 0.052)",
|
||||
vignette: "radial-gradient(ellipse at center, transparent 42%, rgba(2, 3, 5, 0.82) 100%)",
|
||||
},
|
||||
control: {
|
||||
defaultBg: "rgba(255, 255, 255, 0.035)",
|
||||
defaultBorder: "rgba(211, 205, 190, 0.11)",
|
||||
defaultText: "#D7D1C4",
|
||||
hoverBg: "rgba(255, 255, 255, 0.065)",
|
||||
activeBg: "linear-gradient(180deg, rgba(74, 181, 166, 0.24), rgba(38, 118, 116, 0.18))",
|
||||
activeBorder: "rgba(98, 226, 205, 0.42)",
|
||||
activeText: "#E8FFFA",
|
||||
primaryBg: "linear-gradient(180deg, rgba(55, 145, 132, 0.42), rgba(24, 86, 88, 0.28))",
|
||||
primaryBorder: "rgba(99, 228, 206, 0.34)",
|
||||
primaryText: "#F2FFFB",
|
||||
disabledText: "rgba(154, 163, 174, 0.42)",
|
||||
inputBg: "rgba(5, 7, 10, 0.52)",
|
||||
inputBorder: "rgba(211, 205, 190, 0.13)",
|
||||
focusRing: "rgba(98, 226, 205, 0.16)",
|
||||
dangerText: "#FF9A8D",
|
||||
},
|
||||
timeline: {
|
||||
background: "linear-gradient(180deg, rgba(14, 18, 24, 0.86), rgba(8, 11, 15, 0.92))",
|
||||
border: "rgba(170, 184, 205, 0.12)",
|
||||
gridMinor: "rgba(170, 184, 205, 0.04)",
|
||||
gridMajor: "rgba(170, 184, 205, 0.09)",
|
||||
text: "#7A92AE",
|
||||
textStrong: "#A5B7CD",
|
||||
playhead: "#8FE7FF",
|
||||
playheadSoft: "rgba(143, 231, 255, 0.12)",
|
||||
},
|
||||
},
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.66,
|
||||
labelThreshold: 0.985,
|
||||
labelBudget: 10,
|
||||
nodeScale: 0.72,
|
||||
labelThreshold: 0.998,
|
||||
labelBudget: 2,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.34,
|
||||
edgeSizeScale: 0.62,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showCurves: true,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 0.98,
|
||||
labelThreshold: 0.88,
|
||||
labelBudget: 36,
|
||||
nodeScale: 0.94,
|
||||
labelThreshold: 0.95,
|
||||
labelBudget: 12,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: true,
|
||||
showBadges: false,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
inspection: {
|
||||
maxRatio: 0.5,
|
||||
nodeScale: 1,
|
||||
labelThreshold: 0.7,
|
||||
labelBudget: 80,
|
||||
labelThreshold: 0.8,
|
||||
labelBudget: 40,
|
||||
edgePriorityThreshold: 0,
|
||||
arrowPriorityThreshold: 0.58,
|
||||
edgeSizeScale: 1.04,
|
||||
arrowPriorityThreshold: 0.45,
|
||||
edgeSizeScale: 1.18,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
|
||||
forceVisibleStates: ["hovered", "selected", "path"],
|
||||
policies: {
|
||||
none: { minZoomTier: "inspection" },
|
||||
priority: { minZoomTier: "overview" },
|
||||
@@ -391,28 +583,90 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
nodes: {
|
||||
backgroundScale: 0.52,
|
||||
mutedAlpha: 0.08,
|
||||
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.18, minSize: 12.5, forceLabel: true, zIndex: 4, borderBoost: 0.22 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.06, minSize: 10.5, forceLabel: true, zIndex: 3, borderBoost: 0.2 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.84, minSize: 4.8, forceLabel: true, zIndex: 2, borderBoost: -0.08 },
|
||||
path: { color: "path", sizeMultiplier: 1.01, minSize: 6.2, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
default: { color: "base", sizeMultiplier: 0.7, minSize: 0.64, forceLabel: false, zIndex: 0, borderBoost: -0.46 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.08, minSize: 10.4, forceLabel: true, zIndex: 4, borderBoost: 0.2 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.2, forceLabel: true, zIndex: 3, borderBoost: 0.22 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.76, minSize: 4, forceLabel: false, zIndex: 2, borderBoost: -0.08 },
|
||||
path: { color: "path", sizeMultiplier: 0.96, minSize: 5.6, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
|
||||
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: "structure" },
|
||||
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "structure" },
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" },
|
||||
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" },
|
||||
},
|
||||
entityShapes: {
|
||||
entity: {
|
||||
label: "Entity",
|
||||
shapeKind: 0,
|
||||
aspectRatio: 1,
|
||||
fillAlpha: 0.9,
|
||||
shellAlpha: 0.14,
|
||||
coreScale: 0,
|
||||
borderBoost: 0.08,
|
||||
minSize: 0,
|
||||
},
|
||||
biomolecule: {
|
||||
label: "Biomolecule",
|
||||
shapeKind: 1,
|
||||
aspectRatio: 1,
|
||||
fillAlpha: 0.9,
|
||||
shellAlpha: 0.16,
|
||||
coreScale: 0.18,
|
||||
borderBoost: 0.16,
|
||||
minSize: 1.2,
|
||||
},
|
||||
condition: {
|
||||
label: "Condition",
|
||||
shapeKind: 2,
|
||||
aspectRatio: 1.04,
|
||||
fillAlpha: 0.88,
|
||||
shellAlpha: 0.15,
|
||||
coreScale: 0.16,
|
||||
borderBoost: 0.18,
|
||||
minSize: 1.6,
|
||||
},
|
||||
compound: {
|
||||
label: "Compound",
|
||||
shapeKind: 3,
|
||||
aspectRatio: 1.48,
|
||||
fillAlpha: 0.88,
|
||||
shellAlpha: 0.15,
|
||||
coreScale: 0.14,
|
||||
borderBoost: 0.14,
|
||||
minSize: 1.4,
|
||||
},
|
||||
process: {
|
||||
label: "Process",
|
||||
shapeKind: 4,
|
||||
aspectRatio: 1.1,
|
||||
fillAlpha: 0.87,
|
||||
shellAlpha: 0.14,
|
||||
coreScale: 0.14,
|
||||
borderBoost: 0.16,
|
||||
minSize: 1.4,
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
shapeKind: 5,
|
||||
aspectRatio: 1,
|
||||
fillAlpha: 0.68,
|
||||
shellAlpha: 0.28,
|
||||
coreScale: 0.78,
|
||||
borderBoost: 0.34,
|
||||
minSize: 2,
|
||||
},
|
||||
},
|
||||
selectedRing: {
|
||||
color: "#E7C57C",
|
||||
width: 1.9,
|
||||
@@ -437,14 +691,14 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
edges: {
|
||||
states: {
|
||||
default: { color: "structure", sizeMultiplier: 0.74, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 0.72, minSize: 0.18, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 0.92, minSize: 0.5, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.5, minSize: 1.8, zIndex: 4, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
||||
muted: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
||||
default: { color: "structure", sizeMultiplier: 0.48, minSize: 0.2, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 0.62, minSize: 0.36, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 0.96, minSize: 0.86, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.82, minSize: 2.55, zIndex: 6, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
muted: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
},
|
||||
variants: {
|
||||
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
|
||||
@@ -453,6 +707,155 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
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 },
|
||||
},
|
||||
visibility: {
|
||||
full: {
|
||||
overview: {
|
||||
defaultPriorityThreshold: 0.96,
|
||||
backgroundSampleRate: 0.035,
|
||||
defaultAlpha: 0.026,
|
||||
mutedAlpha: 0.012,
|
||||
inactiveAlpha: 0.01,
|
||||
neighborAlpha: 0.26,
|
||||
sizeMultiplier: 0.5,
|
||||
hideMuted: true,
|
||||
},
|
||||
structure: {
|
||||
defaultPriorityThreshold: 0.82,
|
||||
backgroundSampleRate: 0.16,
|
||||
defaultAlpha: 0.04,
|
||||
mutedAlpha: 0.014,
|
||||
inactiveAlpha: 0.012,
|
||||
neighborAlpha: 0.32,
|
||||
sizeMultiplier: 0.62,
|
||||
hideMuted: true,
|
||||
},
|
||||
inspection: {
|
||||
defaultPriorityThreshold: 0.72,
|
||||
backgroundSampleRate: 0.28,
|
||||
defaultAlpha: 0.052,
|
||||
mutedAlpha: 0.012,
|
||||
inactiveAlpha: 0.01,
|
||||
neighborAlpha: 0.38,
|
||||
sizeMultiplier: 0.64,
|
||||
hideMuted: true,
|
||||
},
|
||||
},
|
||||
grouped: {
|
||||
overview: {
|
||||
defaultPriorityThreshold: 0.42,
|
||||
backgroundSampleRate: 1,
|
||||
defaultAlpha: 0.18,
|
||||
mutedAlpha: 0.06,
|
||||
inactiveAlpha: 0.04,
|
||||
neighborAlpha: 0.36,
|
||||
sizeMultiplier: 0.72,
|
||||
hideMuted: false,
|
||||
},
|
||||
structure: {
|
||||
defaultPriorityThreshold: 0.34,
|
||||
backgroundSampleRate: 1,
|
||||
defaultAlpha: 0.2,
|
||||
mutedAlpha: 0.07,
|
||||
inactiveAlpha: 0.05,
|
||||
neighborAlpha: 0.42,
|
||||
sizeMultiplier: 0.78,
|
||||
hideMuted: false,
|
||||
},
|
||||
inspection: {
|
||||
defaultPriorityThreshold: 0.28,
|
||||
backgroundSampleRate: 1,
|
||||
defaultAlpha: 0.22,
|
||||
mutedAlpha: 0.08,
|
||||
inactiveAlpha: 0.06,
|
||||
neighborAlpha: 0.46,
|
||||
sizeMultiplier: 0.82,
|
||||
hideMuted: false,
|
||||
},
|
||||
},
|
||||
focused: {
|
||||
overview: {
|
||||
defaultPriorityThreshold: 0.72,
|
||||
backgroundSampleRate: 0.7,
|
||||
defaultAlpha: 0.1,
|
||||
mutedAlpha: 0.03,
|
||||
inactiveAlpha: 0.02,
|
||||
neighborAlpha: 0.06,
|
||||
sizeMultiplier: 0.72,
|
||||
hideMuted: true,
|
||||
},
|
||||
structure: {
|
||||
defaultPriorityThreshold: 0.6,
|
||||
backgroundSampleRate: 0.8,
|
||||
defaultAlpha: 0.12,
|
||||
mutedAlpha: 0.035,
|
||||
inactiveAlpha: 0.025,
|
||||
neighborAlpha: 0.08,
|
||||
sizeMultiplier: 0.8,
|
||||
hideMuted: true,
|
||||
},
|
||||
inspection: {
|
||||
defaultPriorityThreshold: 0.52,
|
||||
backgroundSampleRate: 0.9,
|
||||
defaultAlpha: 0.14,
|
||||
mutedAlpha: 0.04,
|
||||
inactiveAlpha: 0.03,
|
||||
neighborAlpha: 0.1,
|
||||
sizeMultiplier: 0.88,
|
||||
hideMuted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
contextCaps: {
|
||||
full: {
|
||||
overview: 0,
|
||||
structure: 12,
|
||||
inspection: 24,
|
||||
},
|
||||
grouped: {
|
||||
overview: 6,
|
||||
structure: 8,
|
||||
inspection: 10,
|
||||
},
|
||||
focused: {
|
||||
overview: 24,
|
||||
structure: 36,
|
||||
inspection: 48,
|
||||
},
|
||||
},
|
||||
fullGraphStructure: {
|
||||
ambientBackboneAlpha: 0.12,
|
||||
backboneAlpha: 0.08,
|
||||
bridgeAlpha: 0.14,
|
||||
bridgeCurvePriorityThreshold: 0.78,
|
||||
bridgeCurveStrength: 0.1,
|
||||
backboneMaxSize: 0.5,
|
||||
bridgeMaxSize: 0.7,
|
||||
structureEdgeAlpha: 0.12,
|
||||
inspectionEdgeAlpha: 0.1,
|
||||
},
|
||||
// Staged rollout — set mode to "auto" to enable cross-community curve rendering.
|
||||
// Currently "off" so the canvas overlay layer is inactive in production.
|
||||
fullGraphStructureLayer: {
|
||||
mode: "off",
|
||||
minimumLiteralEdges: 24,
|
||||
minimumCurves: 8,
|
||||
maxCurves: 64,
|
||||
bridgeAlpha: 0.16,
|
||||
backboneAlpha: 0.1,
|
||||
bridgeLineWidth: 0.9,
|
||||
backboneLineWidth: 0.62,
|
||||
curveStrength: 0.12,
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
localContextAlpha: 0.32,
|
||||
hoverContextAlpha: 0.32,
|
||||
selectedEdgeAlpha: 0.6,
|
||||
pathEdgeAlpha: 0.76,
|
||||
localContextMaxSize: 0.6,
|
||||
selectedEdgeMaxSize: 1.0,
|
||||
pathEdgeMaxSize: 1.4,
|
||||
pathOverlayAlpha: 0.16,
|
||||
},
|
||||
overlays: {
|
||||
hoverGlowAlpha: 0.18,
|
||||
@@ -473,6 +876,35 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
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",
|
||||
@@ -530,7 +962,7 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
maxGroups: 8,
|
||||
},
|
||||
diagnostics: {
|
||||
enabledInDev: import.meta.env.DEV,
|
||||
enabledInDev: IS_DEV,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -570,7 +1002,7 @@ export function withAlpha(color: string | undefined, alpha: number): string {
|
||||
}
|
||||
|
||||
if (color.startsWith("rgba(")) {
|
||||
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
return color.replace(/rgba\((.*?),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
}
|
||||
|
||||
if (color.startsWith("rgb(")) {
|
||||
@@ -42,6 +42,7 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
}
|
||||
|
||||
const selected = context.getSelectedNodeState();
|
||||
const displayState = context.getDisplayState();
|
||||
if (!selected) {
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
@@ -82,6 +83,11 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
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,
|
||||
@@ -97,6 +103,34 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
<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) => (
|
||||
@@ -159,6 +193,16 @@ const neighborButtonStyle: CSSProperties = {
|
||||
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,
|
||||
@@ -6,6 +6,7 @@ import type { GraphTheme } from "../graphTheme";
|
||||
import type { GraphSceneRuntime } from "../scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphEffectToggle,
|
||||
@@ -31,6 +32,8 @@ export type GraphPluginActionRequest =
|
||||
| { 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 }
|
||||
@@ -77,6 +80,7 @@ export interface GraphPluginContext {
|
||||
getEffectsState: () => GraphEffectsState;
|
||||
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
|
||||
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
|
||||
getDisplayState: () => GraphDisplayStateSnapshot;
|
||||
isPanelOpen: (panelId: string) => boolean;
|
||||
dispatchAction: (action: GraphPluginActionRequest) => void;
|
||||
}
|
||||
@@ -5,11 +5,14 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphDistanceVisualState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
GraphLayoutSource,
|
||||
GraphLayoutStatus,
|
||||
GraphRuntimeDiagnosticsSnapshot,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
@@ -22,6 +25,8 @@ export interface GraphSceneRuntime {
|
||||
scene: unknown;
|
||||
graph: GraphSceneGraph;
|
||||
displayGraph: GraphSceneGraph;
|
||||
graphVersion: number;
|
||||
layoutMode?: GraphDisplayMeta["layoutMode"];
|
||||
requestRender: () => void;
|
||||
getCameraState: () => GraphCameraState | null;
|
||||
}
|
||||
@@ -31,16 +36,23 @@ export interface GraphSceneEventMap {
|
||||
onEdgeSelect?: (edgeId: string) => void;
|
||||
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
|
||||
onCameraStateChange?: (cameraState: GraphCameraState) => void;
|
||||
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
|
||||
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => 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[];
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
effectsState: GraphEffectsState;
|
||||
temporalState?: GraphTemporalState | null;
|
||||
isLayoutRunning: boolean;
|
||||
@@ -56,6 +68,8 @@ export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
export interface GraphSceneHandle {
|
||||
fitView: () => void;
|
||||
focusNode: (nodeId: string) => void;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
getRuntime: () => GraphSceneRuntime | null;
|
||||
setLayoutRunning?: (running: boolean) => void;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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";
|
||||
import { GRAPH_THEME, type GraphEntityShapeVariant, withAlpha } from "./graphTheme";
|
||||
|
||||
type SemanticaNodeDrawData = {
|
||||
x: number;
|
||||
@@ -16,39 +16,106 @@ type SemanticaNodeDrawData = {
|
||||
shellColor?: string;
|
||||
coreScale?: number;
|
||||
borderColor?: string;
|
||||
borderSize?: number;
|
||||
ringColor?: string;
|
||||
ringSize?: number;
|
||||
entityShape?: GraphEntityShapeVariant;
|
||||
entityShapeKind?: number;
|
||||
entityAspectRatio?: number;
|
||||
nodeType?: string;
|
||||
};
|
||||
|
||||
const MINERAL_DISC_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
const ENTITY_TOKEN_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
|
||||
const MINERAL_DISC_FRAGMENT_SHADER = /* glsl */ `
|
||||
const ENTITY_TOKEN_FRAGMENT_SHADER = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_bodyColor;
|
||||
varying vec4 v_glyphColor;
|
||||
varying vec4 v_outlineColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
varying float v_outlineSize;
|
||||
varying float v_glyphScale;
|
||||
varying float v_shapeKind;
|
||||
varying float v_aspectRatio;
|
||||
|
||||
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);
|
||||
float hexMetric(vec2 point) {
|
||||
vec2 q = abs(point);
|
||||
return max(q.y, q.x * 0.8660254 + q.y * 0.5);
|
||||
}
|
||||
|
||||
vec2 rotate45(vec2 point) {
|
||||
const float invSqrt2 = 0.70710678;
|
||||
return vec2(
|
||||
(point.x - point.y) * invSqrt2,
|
||||
(point.x + point.y) * invSqrt2
|
||||
);
|
||||
}
|
||||
|
||||
float roundedBoxDistance(vec2 point, vec2 halfSize, float radius) {
|
||||
vec2 q = abs(point) - halfSize + vec2(radius);
|
||||
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
|
||||
}
|
||||
|
||||
float capsuleDistance(vec2 point) {
|
||||
vec2 q = vec2(max(abs(point.x) - 0.44, 0.0), point.y);
|
||||
return length(q) - 0.56;
|
||||
}
|
||||
|
||||
float shapeDistance(vec2 point, float shapeKind) {
|
||||
if (shapeKind < 0.5) {
|
||||
return length(point) - 1.0;
|
||||
}
|
||||
if (shapeKind < 1.5) {
|
||||
return hexMetric(point) - 0.92;
|
||||
}
|
||||
if (shapeKind < 2.5) {
|
||||
return roundedBoxDistance(rotate45(point), vec2(0.58, 0.58), 0.18);
|
||||
}
|
||||
if (shapeKind < 3.5) {
|
||||
return capsuleDistance(point);
|
||||
}
|
||||
if (shapeKind < 4.5) {
|
||||
return roundedBoxDistance(point, vec2(0.78, 0.78), 0.24);
|
||||
}
|
||||
return length(point) - 1.0;
|
||||
}
|
||||
|
||||
float glyphDistance(vec2 point, float shapeKind, float scale) {
|
||||
vec2 scaled = point / max(scale, 0.08);
|
||||
if (shapeKind < 0.5) {
|
||||
return 1.0;
|
||||
}
|
||||
if (shapeKind < 1.5) {
|
||||
return abs(hexMetric(scaled) - 0.74) - 0.055;
|
||||
}
|
||||
if (shapeKind < 2.5) {
|
||||
return abs(abs(scaled.x) + abs(scaled.y) - 0.78) - 0.045;
|
||||
}
|
||||
if (shapeKind < 3.5) {
|
||||
return roundedBoxDistance(scaled, vec2(0.56, 0.07), 0.07);
|
||||
}
|
||||
if (shapeKind < 4.5) {
|
||||
return abs(roundedBoxDistance(scaled, vec2(0.48, 0.48), 0.18)) - 0.045;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 unit = v_diffVector / max(v_radius, 0.0001);
|
||||
float metric = discMetric(unit);
|
||||
vec2 unit = vec2(
|
||||
v_diffVector.x / max(v_radius * v_aspectRatio, 0.0001),
|
||||
v_diffVector.y / max(v_radius, 0.0001)
|
||||
);
|
||||
float aa = (2.4 * u_correctionRatio) / max(v_radius, 1.0);
|
||||
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, metric);
|
||||
float distance = shapeDistance(unit, v_shapeKind);
|
||||
float alpha = 1.0 - smoothstep(-aa, aa, distance);
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
if (alpha <= 0.0) {
|
||||
@@ -63,52 +130,63 @@ void main(void) {
|
||||
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);
|
||||
float outlineNorm = clamp(v_outlineSize / max(v_radius, 1.0), 0.035, 0.28);
|
||||
float outlineBlend = 1.0 - smoothstep(-outlineNorm - aa, -outlineNorm + aa, distance);
|
||||
float isOutline = 1.0 - outlineBlend;
|
||||
float topLight = clamp((-unit.y + 0.85) * 0.5, 0.0, 1.0);
|
||||
vec4 color = v_bodyColor;
|
||||
color.rgb += vec3(0.014) * pow(topLight, 2.2);
|
||||
|
||||
if (ringNorm > 0.0 && metric >= ringStart) {
|
||||
color = v_ringColor;
|
||||
if (isOutline > 0.0) {
|
||||
color = mix(color, v_outlineColor, isOutline);
|
||||
}
|
||||
|
||||
float glyphVisible = step(7.25, v_radius) * step(0.13, v_glyphScale) * step(0.5, v_shapeKind) * (1.0 - step(4.5, v_shapeKind));
|
||||
float glyph = (1.0 - smoothstep(-aa * 1.4, aa * 1.4, glyphDistance(unit, v_shapeKind, clamp(v_glyphScale, 0.16, 0.52)))) * glyphVisible;
|
||||
if (glyph > 0.0 && distance < -outlineNorm) {
|
||||
color = mix(color, v_glyphColor, glyph * 0.38);
|
||||
}
|
||||
|
||||
color.a *= alpha;
|
||||
gl_FragColor = color;
|
||||
#endif
|
||||
}
|
||||
`;
|
||||
|
||||
const MINERAL_DISC_VERTEX_SHADER = /* glsl */ `
|
||||
const ENTITY_TOKEN_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;
|
||||
attribute vec4 a_bodyColor;
|
||||
attribute vec4 a_glyphColor;
|
||||
attribute vec4 a_outlineColor;
|
||||
attribute float a_outlineSize;
|
||||
attribute float a_glyphScale;
|
||||
attribute float a_shapeKind;
|
||||
attribute float a_aspectRatio;
|
||||
|
||||
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_bodyColor;
|
||||
varying vec4 v_glyphColor;
|
||||
varying vec4 v_outlineColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
varying float v_outlineSize;
|
||||
varying float v_glyphScale;
|
||||
varying float v_shapeKind;
|
||||
varying float v_aspectRatio;
|
||||
|
||||
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));
|
||||
float aspect = max(a_aspectRatio, 1.0);
|
||||
vec2 diffVector = size * vec2(cos(a_angle) * aspect, sin(a_angle));
|
||||
vec2 position = a_position + diffVector;
|
||||
|
||||
gl_Position = vec4(
|
||||
@@ -119,22 +197,24 @@ void main() {
|
||||
|
||||
v_diffVector = diffVector;
|
||||
v_radius = size / 2.0;
|
||||
v_ringSize = a_ringSize;
|
||||
v_coreScale = a_coreScale;
|
||||
v_outlineSize = a_outlineSize;
|
||||
v_glyphScale = a_glyphScale;
|
||||
v_shapeKind = a_shapeKind;
|
||||
v_aspectRatio = aspect;
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
v_color = a_id;
|
||||
#else
|
||||
v_coreColor = a_coreColor;
|
||||
v_shellColor = a_shellColor;
|
||||
v_ringColor = a_ringColor;
|
||||
v_bodyColor = a_bodyColor;
|
||||
v_glyphColor = a_glyphColor;
|
||||
v_outlineColor = a_outlineColor;
|
||||
#endif
|
||||
|
||||
v_color.a *= bias;
|
||||
}
|
||||
`;
|
||||
|
||||
class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[number]> {
|
||||
class EntityTokenNodeProgram extends NodeProgram<(typeof ENTITY_TOKEN_UNIFORMS)[number]> {
|
||||
static readonly ANGLE_1 = 0;
|
||||
static readonly ANGLE_2 = (2 * Math.PI) / 3;
|
||||
static readonly ANGLE_3 = (4 * Math.PI) / 3;
|
||||
@@ -146,47 +226,52 @@ class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[
|
||||
getDefinition() {
|
||||
return {
|
||||
VERTICES: 3,
|
||||
VERTEX_SHADER_SOURCE: MINERAL_DISC_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: MINERAL_DISC_FRAGMENT_SHADER,
|
||||
VERTEX_SHADER_SOURCE: ENTITY_TOKEN_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: ENTITY_TOKEN_FRAGMENT_SHADER,
|
||||
METHOD: WebGLRenderingContext.TRIANGLES,
|
||||
UNIFORMS: MINERAL_DISC_UNIFORMS,
|
||||
UNIFORMS: ENTITY_TOKEN_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_bodyColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_glyphColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_outlineColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_outlineSize", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_glyphScale", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_shapeKind", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_aspectRatio", 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],
|
||||
[EntityTokenNodeProgram.ANGLE_1],
|
||||
[EntityTokenNodeProgram.ANGLE_2],
|
||||
[EntityTokenNodeProgram.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);
|
||||
const outlineColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
|
||||
const outlineSize = Math.max(data.ringSize || 0, data.borderSize || 0.7);
|
||||
|
||||
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++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, 0.58));
|
||||
array[startIndex++] = floatColor(outlineColor);
|
||||
array[startIndex++] = outlineSize;
|
||||
array[startIndex++] = data.coreScale ?? 0.22;
|
||||
array[startIndex++] = data.entityShapeKind ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].shapeKind;
|
||||
array[startIndex++] = data.entityAspectRatio ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].aspectRatio;
|
||||
array[startIndex++] = nodeIndex;
|
||||
}
|
||||
|
||||
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof MINERAL_DISC_UNIFORMS)[number]>): void {
|
||||
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof ENTITY_TOKEN_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);
|
||||
@@ -326,7 +411,7 @@ export const drawSemanticaNodeHover: NodeHoverDrawingFunction = (context, rawDat
|
||||
|
||||
export const SEMANTICA_NODE_PROGRAM_CLASSES = {
|
||||
...DEFAULT_NODE_PROGRAM_CLASSES,
|
||||
circle: MineralDiscNodeProgram,
|
||||
circle: EntityTokenNodeProgram,
|
||||
};
|
||||
|
||||
export const SEMANTICA_EDGE_PROGRAM_CLASSES = {
|
||||
@@ -1,4 +1,4 @@
|
||||
export type GraphViewMode = "focused" | "full";
|
||||
export type GraphViewMode = "focused" | "full" | "grouped";
|
||||
export type GraphLayoutSource = "provided" | "carried" | "runtime";
|
||||
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
|
||||
export type GraphLoadPhase =
|
||||
@@ -12,6 +12,45 @@ export type GraphLoadPhase =
|
||||
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 GraphFullEdgeClass = "hidden" | "backbone" | "bridge" | "local-context" | "selected" | "path" | "muted";
|
||||
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
|
||||
export type GraphDistanceVisualMode = "off" | "ego" | "heatmap" | "structural" | "semantic";
|
||||
export type GraphDistanceVisualStatus = "idle" | "loading" | "ready" | "unavailable" | "error";
|
||||
|
||||
export interface GraphDistanceBucketCounts {
|
||||
anchor: number;
|
||||
oneHop: number;
|
||||
twoHop: number;
|
||||
threeHopPlus: number;
|
||||
outside: number;
|
||||
}
|
||||
|
||||
export type GraphHeatmapSaturationMode = "normal" | "sampled";
|
||||
|
||||
export interface GraphHeatmapRenderSnapshot {
|
||||
visibleNodeIds: string[];
|
||||
ringCounts: GraphDistanceBucketCounts;
|
||||
renderedRingCounts: GraphDistanceBucketCounts;
|
||||
saturationMode: GraphHeatmapSaturationMode;
|
||||
}
|
||||
|
||||
export interface GraphDistanceVisualState {
|
||||
mode: GraphDistanceVisualMode;
|
||||
anchorNodeId: string | null;
|
||||
anchorLabel?: string | null;
|
||||
maxHops: number;
|
||||
structuralDistances: Record<string, number>;
|
||||
semanticScores: Record<string, number>;
|
||||
distanceCounts?: GraphDistanceBucketCounts;
|
||||
outsideCount?: number;
|
||||
heatmapVisibleNodeIds?: string[];
|
||||
heatmapRingCounts?: GraphDistanceBucketCounts;
|
||||
heatmapRenderedRingCounts?: GraphDistanceBucketCounts;
|
||||
heatmapSaturationMode?: GraphHeatmapSaturationMode;
|
||||
semanticNeighborCount?: number;
|
||||
status: GraphDistanceVisualStatus;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface GraphCameraState {
|
||||
x: number;
|
||||
@@ -31,6 +70,28 @@ export interface GraphInteractionState {
|
||||
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"
|
||||
@@ -69,11 +130,51 @@ export interface GraphEffectAvailability {
|
||||
segmentCap?: number;
|
||||
}
|
||||
|
||||
export type GraphFullEdgeClassCounts = Record<GraphFullEdgeClass, number>;
|
||||
|
||||
export interface GraphFullEdgeClassDiagnostics {
|
||||
mode: GraphViewMode;
|
||||
zoomTier: GraphInteractionState["zoomTier"];
|
||||
totalEdges: number;
|
||||
visibleEdges: number;
|
||||
counts: GraphFullEdgeClassCounts;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type GraphStructureLayerDisabledReason =
|
||||
| "non-full-mode"
|
||||
| "layout-running"
|
||||
| "enough-literal-edges"
|
||||
| "no-eligible-edges"
|
||||
| "invalid-layer"
|
||||
| "cache-empty"
|
||||
| "disabled";
|
||||
|
||||
export interface GraphStructureLayerDiagnostics {
|
||||
enabled: boolean;
|
||||
disabledReason: GraphStructureLayerDisabledReason | null;
|
||||
curveCount: number;
|
||||
bridgeCurveCount: number;
|
||||
backboneCurveCount: number;
|
||||
cacheKey: string;
|
||||
lastDrawAt: number | null;
|
||||
}
|
||||
|
||||
export interface GraphRuntimeDiagnosticsSnapshot {
|
||||
effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"];
|
||||
edgeClasses?: GraphFullEdgeClassDiagnostics;
|
||||
structureLayer?: GraphStructureLayerDiagnostics;
|
||||
distanceVisual?: GraphDistanceVisualState;
|
||||
}
|
||||
|
||||
export interface GraphDiagnosticsSnapshot {
|
||||
interactionState: GraphInteractionState;
|
||||
activePluginIds: string[];
|
||||
openPanelIds: string[];
|
||||
effectsState: GraphEffectsState;
|
||||
edgeClasses?: GraphFullEdgeClassDiagnostics;
|
||||
structureLayer?: GraphStructureLayerDiagnostics;
|
||||
distanceVisual?: GraphDistanceVisualState;
|
||||
effectAvailability: {
|
||||
pathPulse: GraphEffectAvailability;
|
||||
pathFlow: GraphEffectAvailability;
|
||||
@@ -245,6 +346,10 @@ export interface GraphSelectedNodeState {
|
||||
valid_until?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
neighborCount: number;
|
||||
visibleNeighborCount: number;
|
||||
collapsedNeighborCount: number;
|
||||
isNeighborhoodCollapsed: boolean;
|
||||
canCollapseNeighborhood: boolean;
|
||||
}
|
||||
|
||||
export interface GraphSelectedEdgeState {
|
||||
@@ -260,6 +365,12 @@ export interface GraphSelectedEdgeState {
|
||||
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 {
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
withAlpha,
|
||||
type GraphBadgeKind,
|
||||
type GraphEdgeVariant,
|
||||
type GraphEntityShapeVariant,
|
||||
type GraphLabelVisibilityPolicy,
|
||||
type GraphNodeShapeVariant,
|
||||
} from "./graphTheme";
|
||||
import { classifyEntityShape } from "./graphEntityShape";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import type { GraphLoadProgress, GraphLoadSummary } from "./types";
|
||||
|
||||
@@ -196,6 +198,15 @@ function getProvenanceCount(properties: Record<string, unknown>): number {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEntityShape(attributes: NodeAttributes, semanticGroup: string): GraphEntityShapeVariant {
|
||||
return classifyEntityShape(
|
||||
attributes.nodeType,
|
||||
semanticGroup,
|
||||
attributes.content,
|
||||
attributes.properties as Record<string, unknown> | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNodeVariantMetadata(
|
||||
baseColor: string,
|
||||
sizeRatio: number,
|
||||
@@ -548,6 +559,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
|
||||
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
|
||||
const properties = attributes.properties as Record<string, unknown>;
|
||||
const entityShape = resolveEntityShape(attributes, semanticGroup);
|
||||
const providedX = readFiniteCoordinate(properties?.x);
|
||||
const providedY = readFiniteCoordinate(properties?.y);
|
||||
const seededPosition = seededPositions?.get(id);
|
||||
@@ -575,6 +587,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
strokeColor: darkenHex(baseColor, 112),
|
||||
borderColor: darkenHex(baseColor, 112),
|
||||
borderSize: 0.72,
|
||||
entityShape,
|
||||
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
|
||||
} as NodeAttributes,
|
||||
};
|
||||
@@ -598,6 +611,12 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
const parallelIndex = parallelOffsets.get(pairKey) ?? 0;
|
||||
parallelOffsets.set(pairKey, parallelIndex + 1);
|
||||
const parallelCount = parallelCounts.get(pairKey) ?? 1;
|
||||
const normalizedWeight = clamp(0, Math.log1p(Math.max(Number(edge.weight) || 1, 1)) / 6, 1);
|
||||
const edgeVisualPriority = clamp(
|
||||
0,
|
||||
Math.sqrt(Math.max(sourcePriority, 0) * Math.max(targetPriority, 0)) * 0.72 + normalizedWeight * 0.28,
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
@@ -617,7 +636,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
color: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
baseColor: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
|
||||
visualPriority: Math.max(sourcePriority, targetPriority),
|
||||
visualPriority: edgeVisualPriority,
|
||||
isBidirectional,
|
||||
edgeFamily: isBidirectional ? "bidirectional" : "line",
|
||||
curveGroup: curveGroupForPair(edge.source, edge.target),
|
||||
@@ -4,6 +4,7 @@
|
||||
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 {
|
||||
@@ -107,6 +108,11 @@ export function ImportExportWorkspace() {
|
||||
|
||||
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");
|
||||
@@ -142,6 +148,7 @@ export function ImportExportWorkspace() {
|
||||
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 {
|
||||
@@ -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,913 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
FileUp,
|
||||
Globe,
|
||||
Loader2,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
type LoaderMode = "url" | "file" | "create";
|
||||
type CreateMode = "scratch" | "data" | "text";
|
||||
|
||||
interface OntologyPreview {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
version?: string;
|
||||
license?: string;
|
||||
format: string;
|
||||
estimated_triples: number;
|
||||
source_url?: string;
|
||||
}
|
||||
|
||||
interface LoaderProps {
|
||||
onLoaded: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Badge({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.07em",
|
||||
textTransform: "uppercase" as const,
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}33`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<label style={fieldLabelStyle}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={inputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Textarea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 5,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
style={{ ...inputStyle, resize: "vertical", fontFamily: "monospace" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewCard({ preview }: { preview: OntologyPreview }) {
|
||||
return (
|
||||
<div style={previewCardStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<CheckCircle2 size={16} color="#4cc38a" />
|
||||
<span style={{ color: "#4cc38a", fontSize: 12, fontWeight: 700 }}>
|
||||
Preview ready
|
||||
</span>
|
||||
<Badge label={preview.format} color="#58a6ff" />
|
||||
</div>
|
||||
|
||||
<div style={previewTitleStyle}>{preview.name}</div>
|
||||
{preview.description && (
|
||||
<div style={{ color: "#8fa8c6", fontSize: 12, marginTop: 6, lineHeight: 1.5 }}>
|
||||
{preview.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={previewGridStyle}>
|
||||
<PreviewRow label="Namespace" value={preview.namespace || preview.uri} mono />
|
||||
{preview.version && <PreviewRow label="Version" value={preview.version} />}
|
||||
{preview.license && <PreviewRow label="License" value={preview.license} />}
|
||||
<PreviewRow
|
||||
label="Estimated triples"
|
||||
value={preview.estimated_triples.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#c6d4e3",
|
||||
fontSize: 11,
|
||||
fontFamily: mono ? "monospace" : undefined,
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL Import panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function URLImportPanel({ onLoaded }: { onLoaded: () => void }) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [format, setFormat] = useState("");
|
||||
const [customName, setCustomName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [preview, setPreview] = useState<OntologyPreview | null>(null);
|
||||
const [previewState, setPreviewState] = useState<"idle" | "loading" | "error">("idle");
|
||||
const [loadState, setLoadState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!url.trim()) return;
|
||||
setPreviewState("loading");
|
||||
setPreview(null);
|
||||
setErrorMsg("");
|
||||
try {
|
||||
const res = await fetch("/api/ontology/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: url.trim(), format: format || undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(err.detail || "Preview failed");
|
||||
}
|
||||
setPreview(await res.json());
|
||||
setPreviewState("idle");
|
||||
} catch (e) {
|
||||
setPreviewState("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Could not fetch preview");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = async () => {
|
||||
if (!url.trim()) return;
|
||||
setLoadState("loading");
|
||||
try {
|
||||
const res = await fetch("/api/ontology/load", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: url.trim(),
|
||||
format: format || undefined,
|
||||
name: customName || undefined,
|
||||
description: description || undefined,
|
||||
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Load failed" }));
|
||||
throw new Error(err.detail || "Load failed");
|
||||
}
|
||||
setLoadState("success");
|
||||
setTimeout(() => {
|
||||
setLoadState("idle");
|
||||
onLoaded();
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
setLoadState("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Load failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelBodyStyle}>
|
||||
<FieldGroup label="Ontology URL">
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setPreview(null);
|
||||
setPreviewState("idle");
|
||||
}}
|
||||
placeholder="https://schema.org/version/latest/schema.ttl"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={!url.trim() || previewState === "loading"}
|
||||
style={previewBtnStyle}
|
||||
>
|
||||
{previewState === "loading" ? (
|
||||
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
|
||||
) : (
|
||||
"Fetch Preview"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
{previewState === "error" && (
|
||||
<div style={errorBoxStyle}>
|
||||
<AlertCircle size={13} />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && <PreviewCard preview={preview} />}
|
||||
|
||||
<button
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
style={advancedToggleStyle}
|
||||
>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{ transform: showAdvanced ? "rotate(180deg)" : undefined, transition: "200ms" }}
|
||||
/>
|
||||
Advanced options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<FieldGroup label="Format override">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">Auto-detect</option>
|
||||
<option value="turtle">Turtle (.ttl)</option>
|
||||
<option value="xml">RDF/XML (.rdf, .owl)</option>
|
||||
<option value="nt">N-Triples (.nt)</option>
|
||||
<option value="json-ld">JSON-LD (.jsonld)</option>
|
||||
</select>
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Custom display name">
|
||||
<Input value={customName} onChange={setCustomName} placeholder="Leave blank to use ontology title" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Description">
|
||||
<Input value={description} onChange={setDescription} placeholder="Optional description" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Tags (comma-separated)">
|
||||
<Input value={tags} onChange={setTags} placeholder="e.g. biology, upper-ontology" />
|
||||
</FieldGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === "success" && (
|
||||
<div style={successBoxStyle}>
|
||||
<CheckCircle2 size={13} />
|
||||
<span>Ontology loaded successfully</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === "error" && (
|
||||
<div style={errorBoxStyle}>
|
||||
<AlertCircle size={13} />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 4 }}>
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={!url.trim() || loadState === "loading"}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
{loadState === "loading" ? (
|
||||
<>
|
||||
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe size={13} />
|
||||
Load Ontology
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File Upload panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FileUploadPanel({ onLoaded }: { onLoaded: () => void }) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [format, setFormat] = useState("");
|
||||
const [loadState, setLoadState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setFileName(file.name);
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
const fmtMap: Record<string, string> = {
|
||||
ttl: "turtle", rdf: "xml", owl: "xml", xml: "xml",
|
||||
nt: "nt", jsonld: "json-ld", json: "json-ld",
|
||||
};
|
||||
// Leave format empty for unknown extensions so the backend auto-detects
|
||||
setFormat(fmtMap[ext] ?? "");
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setContent(e.target?.result as string || "");
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
const handleLoad = async () => {
|
||||
if (!content) return;
|
||||
setLoadState("loading");
|
||||
try {
|
||||
const res = await fetch("/api/ontology/load", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// Omit format when empty so the backend _detect_format() runs
|
||||
body: JSON.stringify({ content, ...(format ? { format } : {}) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Load failed" }));
|
||||
throw new Error(err.detail || "Load failed");
|
||||
}
|
||||
setLoadState("success");
|
||||
setTimeout(() => {
|
||||
setLoadState("idle");
|
||||
onLoaded();
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
setLoadState("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Load failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelBodyStyle}>
|
||||
<div
|
||||
style={{
|
||||
...dropzoneStyle,
|
||||
borderColor: dragging
|
||||
? "rgba(74,163,255,0.5)"
|
||||
: "rgba(127,208,255,0.18)",
|
||||
background: dragging ? "rgba(74,163,255,0.06)" : undefined,
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<FileUp size={24} color="#4aa3ff" />
|
||||
{fileName ? (
|
||||
<div style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 600 }}>{fileName}</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13 }}>
|
||||
Drop a file here or <span style={{ color: "#4aa3ff" }}>browse</span>
|
||||
</div>
|
||||
<div style={{ color: "#5a7a9a", fontSize: 11 }}>
|
||||
.ttl · .rdf · .owl · .xml · .nt · .jsonld · .json · .n3
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".ttl,.rdf,.owl,.nt,.jsonld,.json,.xml,.n3"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{content && (
|
||||
<FieldGroup label="Format">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="turtle">Turtle</option>
|
||||
<option value="xml">RDF/XML</option>
|
||||
<option value="nt">N-Triples</option>
|
||||
<option value="json-ld">JSON-LD</option>
|
||||
</select>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{loadState === "success" && (
|
||||
<div style={successBoxStyle}>
|
||||
<CheckCircle2 size={13} />
|
||||
<span>Ontology loaded successfully — {fileName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === "error" && (
|
||||
<div style={errorBoxStyle}>
|
||||
<AlertCircle size={13} />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={!content || loadState === "loading"}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
{loadState === "loading" ? (
|
||||
<>
|
||||
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileUp size={13} />
|
||||
Load File
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create New panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CreateNewPanel({ onLoaded }: { onLoaded: () => void }) {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("scratch");
|
||||
const [namespace, setNamespace] = useState("https://example.org/ontology/");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [sampleData, setSampleData] = useState("");
|
||||
const [schemaText, setSchemaText] = useState("");
|
||||
const [createState, setCreateState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim() || !namespace.trim()) return;
|
||||
setCreateState("loading");
|
||||
try {
|
||||
const res = await fetch("/api/ontology/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: createMode,
|
||||
namespace: namespace.trim(),
|
||||
name: name.trim(),
|
||||
description: description || undefined,
|
||||
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
||||
sample_data: createMode === "data" ? sampleData : undefined,
|
||||
schema_text: createMode === "text" ? schemaText : undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Create failed" }));
|
||||
throw new Error(err.detail || "Create failed");
|
||||
}
|
||||
setCreateState("success");
|
||||
setTimeout(() => {
|
||||
setCreateState("idle");
|
||||
onLoaded();
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
setCreateState("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Create failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{(["scratch", "data", "text"] as CreateMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setCreateMode(m)}
|
||||
style={{
|
||||
...modeTabBase,
|
||||
...(createMode === m ? modeTabActive : modeTabIdle),
|
||||
}}
|
||||
>
|
||||
{m === "scratch" ? "From Scratch" : m === "data" ? "From Data" : "From Text"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FieldGroup label="Display Name *">
|
||||
<Input value={name} onChange={setName} placeholder="My Ontology" />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup label="Namespace URI *">
|
||||
<Input value={namespace} onChange={setNamespace} placeholder="https://example.org/onto/" />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup label="Description">
|
||||
<Input value={description} onChange={setDescription} placeholder="Optional description" />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup label="Tags (comma-separated)">
|
||||
<Input value={tags} onChange={setTags} placeholder="e.g. internal, draft" />
|
||||
</FieldGroup>
|
||||
|
||||
{createMode === "data" && (
|
||||
<FieldGroup label="Sample Data (JSON or CSV)">
|
||||
<Textarea
|
||||
value={sampleData}
|
||||
onChange={setSampleData}
|
||||
placeholder={'[{"name": "Alice", "age": 30, "city": "Berlin"}]'}
|
||||
rows={6}
|
||||
/>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{createMode === "text" && (
|
||||
<FieldGroup label="Schema Requirements (natural language)">
|
||||
<Textarea
|
||||
value={schemaText}
|
||||
onChange={setSchemaText}
|
||||
placeholder="Describe the ontology you need. E.g.: I need an ontology for a hospital domain with patients, doctors, appointments, and medications."
|
||||
rows={6}
|
||||
/>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{createState === "success" && (
|
||||
<div style={successBoxStyle}>
|
||||
<CheckCircle2 size={13} />
|
||||
<span>Ontology created and opened in the Registry</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createState === "error" && (
|
||||
<div style={errorBoxStyle}>
|
||||
<AlertCircle size={13} />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || !namespace.trim() || createState === "loading"}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
{createState === "loading" ? (
|
||||
<>
|
||||
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={13} />
|
||||
Create Ontology
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main OntologyLoader modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function OntologyLoader({ onLoaded, onClose }: LoaderProps) {
|
||||
const [mode, setMode] = useState<LoaderMode>("url");
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div style={modalStyle}>
|
||||
<div style={modalHeaderStyle}>
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 800 }}>Load Ontology</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 12, marginTop: 2 }}>
|
||||
Import from URL, upload a file, or create a new ontology
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeIconBtnStyle}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 2, padding: "0 20px", borderBottom: "1px solid rgba(127,208,255,0.1)" }}>
|
||||
{(["url", "file", "create"] as LoaderMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
style={{
|
||||
...modalTabBase,
|
||||
...(mode === m ? modalTabActive : modalTabIdle),
|
||||
}}
|
||||
>
|
||||
{m === "url" ? (
|
||||
<><Globe size={12} /> URL Import</>
|
||||
) : m === "file" ? (
|
||||
<><FileUp size={12} /> File Upload</>
|
||||
) : (
|
||||
<><Plus size={12} /> Create New</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={modalBodyStyle}>
|
||||
{mode === "url" && <URLImportPanel onLoaded={onLoaded} />}
|
||||
{mode === "file" && <FileUploadPanel onLoaded={onLoaded} />}
|
||||
{mode === "create" && <CreateNewPanel onLoaded={onLoaded} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(3,9,18,0.78)",
|
||||
backdropFilter: "blur(6px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
width: "min(620px, 96vw)",
|
||||
maxHeight: "88vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 20,
|
||||
border: "1px solid rgba(127,208,255,0.16)",
|
||||
background: "linear-gradient(180deg, rgba(11,21,34,0.98), rgba(6,13,22,0.96))",
|
||||
boxShadow: "0 32px 80px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.06)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const modalHeaderStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
padding: "20px 20px 16px",
|
||||
};
|
||||
|
||||
const modalBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const panelBodyStyle: React.CSSProperties = {
|
||||
padding: "16px 20px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
};
|
||||
|
||||
const modalTabBase: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "8px 14px",
|
||||
border: "none",
|
||||
borderBottom: "2px solid transparent",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const modalTabIdle: React.CSSProperties = {
|
||||
color: "#8fa8c6",
|
||||
};
|
||||
|
||||
const modalTabActive: React.CSSProperties = {
|
||||
color: "#4aa3ff",
|
||||
borderBottomColor: "#4aa3ff",
|
||||
};
|
||||
|
||||
const closeIconBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
borderRadius: 8,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
};
|
||||
|
||||
const fieldLabelStyle: React.CSSProperties = {
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.16)",
|
||||
background: "rgba(0,0,0,0.24)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
...inputStyle,
|
||||
appearance: "none" as const,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const previewBtnStyle: React.CSSProperties = {
|
||||
padding: "8px 14px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.2)",
|
||||
background: "rgba(74,163,255,0.08)",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
};
|
||||
|
||||
const primaryBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "9px 18px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(74,163,255,0.3)",
|
||||
background: "linear-gradient(135deg, rgba(74,163,255,0.2), rgba(74,163,255,0.1))",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const advancedToggleStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#6a7f97",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
const previewCardStyle: React.CSSProperties = {
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(76,195,138,0.18)",
|
||||
background: "rgba(76,195,138,0.04)",
|
||||
};
|
||||
|
||||
const previewTitleStyle: React.CSSProperties = {
|
||||
color: "#ebf3ff",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "-0.03em",
|
||||
};
|
||||
|
||||
const previewGridStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10,
|
||||
marginTop: 12,
|
||||
};
|
||||
|
||||
const dropzoneStyle: React.CSSProperties = {
|
||||
border: "2px dashed",
|
||||
borderRadius: 12,
|
||||
padding: "32px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const successBoxStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(76,195,138,0.22)",
|
||||
background: "rgba(76,195,138,0.06)",
|
||||
color: "#4cc38a",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const errorBoxStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,157,175,0.22)",
|
||||
background: "rgba(255,157,175,0.06)",
|
||||
color: "#ff9daf",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const modeTabBase: React.CSSProperties = {
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const modeTabIdle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
color: "#8fa8c6",
|
||||
borderColor: "rgba(127,208,255,0.1)",
|
||||
};
|
||||
|
||||
const modeTabActive: React.CSSProperties = {
|
||||
background: "rgba(74,163,255,0.14)",
|
||||
color: "#ebf3ff",
|
||||
borderColor: "rgba(127,208,255,0.24)",
|
||||
};
|
||||
@@ -0,0 +1,915 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
Layers,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
} from "lucide-react";
|
||||
import { OntologyLoader } from "./OntologyLoader";
|
||||
import { OntologySearch } from "./OntologySearch";
|
||||
import { SKOSVocabularyManager } from "./SKOSVocabularyManager";
|
||||
|
||||
interface OntologyEntry {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
format: string;
|
||||
status: "published" | "draft" | "external";
|
||||
source_url?: string;
|
||||
version?: string;
|
||||
class_count: number;
|
||||
concept_count: number;
|
||||
property_count: number;
|
||||
loaded_at: string;
|
||||
enabled: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
type RightPanel = "none" | "search" | "skos";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
published: "#4cc38a",
|
||||
draft: "#f2b66d",
|
||||
external: "#58a6ff",
|
||||
};
|
||||
|
||||
const FORMAT_COLORS: Record<string, string> = {
|
||||
turtle: "#9ee8d7",
|
||||
xml: "#ff9daf",
|
||||
"json-ld": "#f2b66d",
|
||||
nt: "#d2a8ff",
|
||||
unknown: "#6a7f97",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const color = STATUS_COLORS[status] || "#6a7f97";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.07em",
|
||||
textTransform: "uppercase" as const,
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}33`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatBadge({ format }: { format: string }) {
|
||||
const color = FORMAT_COLORS[format] || FORMAT_COLORS.unknown;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: `${color}14`,
|
||||
border: `1px solid ${color}28`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{format}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||
<span style={{ color: "#ebf3ff", fontSize: 14, fontWeight: 800 }}>
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistryRow({
|
||||
entry,
|
||||
selected,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onRefresh,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: OntologyEntry;
|
||||
selected: boolean;
|
||||
onSelect: (e: OntologyEntry) => void;
|
||||
onToggle: (uri: string) => void;
|
||||
onRefresh: (uri: string) => void;
|
||||
onRemove: (uri: string) => void;
|
||||
}) {
|
||||
const [busyToggle, setBusyToggle] = useState(false);
|
||||
const [busyRefresh, setBusyRefresh] = useState(false);
|
||||
const [busyRemove, setBusyRemove] = useState(false);
|
||||
|
||||
const handleToggle = async (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
setBusyToggle(true);
|
||||
await onToggle(entry.uri);
|
||||
setBusyToggle(false);
|
||||
};
|
||||
|
||||
const handleRefresh = async (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
setBusyRefresh(true);
|
||||
await onRefresh(entry.uri);
|
||||
setBusyRefresh(false);
|
||||
};
|
||||
|
||||
const handleRemove = async (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!window.confirm(`Remove "${entry.name}" from the registry?`)) return;
|
||||
setBusyRemove(true);
|
||||
await onRemove(entry.uri);
|
||||
setBusyRemove(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(entry)}
|
||||
style={{
|
||||
...rowStyle,
|
||||
background: selected
|
||||
? "rgba(74,163,255,0.1)"
|
||||
: "rgba(255,255,255,0.02)",
|
||||
borderColor: selected
|
||||
? "rgba(127,208,255,0.26)"
|
||||
: "rgba(127,208,255,0.1)",
|
||||
opacity: entry.enabled ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
<div style={rowMainStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={rowNameStyle}>{entry.name}</span>
|
||||
<StatusBadge status={entry.status} />
|
||||
<FormatBadge format={entry.format} />
|
||||
{!entry.enabled && (
|
||||
<span style={disabledBadgeStyle}>Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={rowUriStyle}>{entry.uri}</div>
|
||||
{entry.source_url && (
|
||||
<a
|
||||
href={entry.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={sourceLinkStyle}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
{entry.source_url.slice(0, 60)}{entry.source_url.length > 60 ? "…" : ""}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={rowStatsStyle}>
|
||||
<Stat value={entry.class_count} label="Classes" />
|
||||
<Stat value={entry.concept_count} label="Concepts" />
|
||||
<Stat value={entry.property_count} label="Props" />
|
||||
</div>
|
||||
|
||||
<div style={rowActionsStyle}>
|
||||
<button
|
||||
title={entry.enabled ? "Disable" : "Enable"}
|
||||
onClick={handleToggle}
|
||||
disabled={busyToggle}
|
||||
style={actionBtnStyle}
|
||||
>
|
||||
{busyToggle ? (
|
||||
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
|
||||
) : entry.enabled ? (
|
||||
<ToggleRight size={15} color="#4cc38a" />
|
||||
) : (
|
||||
<ToggleLeft size={15} color="#6a7f97" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{entry.source_url && (
|
||||
<button
|
||||
title="Re-fetch from source URL"
|
||||
onClick={handleRefresh}
|
||||
disabled={busyRefresh}
|
||||
style={actionBtnStyle}
|
||||
>
|
||||
{busyRefresh ? (
|
||||
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
|
||||
) : (
|
||||
<RefreshCw size={13} color="#58a6ff" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
title="Remove from registry"
|
||||
onClick={handleRemove}
|
||||
disabled={busyRemove}
|
||||
style={{ ...actionBtnStyle, color: "#ff9daf" }}
|
||||
>
|
||||
{busyRemove ? (
|
||||
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
|
||||
) : (
|
||||
<Trash2 size={13} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function OntologyManager() {
|
||||
const [entries, setEntries] = useState<OntologyEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQ, setSearchQ] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [showLoader, setShowLoader] = useState(false);
|
||||
const [selectedEntry, setSelectedEntry] = useState<OntologyEntry | null>(null);
|
||||
const [rightPanel, setRightPanel] = useState<RightPanel>("none");
|
||||
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const fetchRegistry = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQ) params.set("q", searchQ);
|
||||
// format/kind filters (owl/skos/internal/external) are applied client-side
|
||||
// via filteredEntries; only text search is delegated to the backend
|
||||
const res = await fetch(`/api/ontology/registry?${params}`);
|
||||
if (!res.ok) throw new Error("Failed to load registry");
|
||||
setEntries(await res.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load registry");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQ, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegistry();
|
||||
}, [fetchRegistry]);
|
||||
|
||||
const flashMsg = (type: "ok" | "err", text: string) => {
|
||||
setActionMsg({ type, text });
|
||||
setTimeout(() => setActionMsg(null), 3000);
|
||||
};
|
||||
|
||||
const handleToggle = useCallback(async (uri: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}/toggle`, {
|
||||
method: "PATCH",
|
||||
});
|
||||
if (!res.ok) throw new Error("Toggle failed");
|
||||
const data = await res.json();
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.uri === uri ? { ...e, enabled: data.enabled } : e))
|
||||
);
|
||||
} catch {
|
||||
flashMsg("err", "Could not toggle ontology");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async (uri: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}/refresh`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error("Refresh failed");
|
||||
flashMsg("ok", "Ontology refreshed");
|
||||
fetchRegistry();
|
||||
} catch {
|
||||
flashMsg("err", "Refresh failed — check source URL");
|
||||
}
|
||||
}, [fetchRegistry]);
|
||||
|
||||
const handleRemove = useCallback(async (uri: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Remove failed");
|
||||
setEntries((prev) => prev.filter((e) => e.uri !== uri));
|
||||
if (selectedEntry?.uri === uri) setSelectedEntry(null);
|
||||
flashMsg("ok", "Removed from registry");
|
||||
} catch {
|
||||
flashMsg("err", "Could not remove ontology");
|
||||
}
|
||||
}, [selectedEntry]);
|
||||
|
||||
const handleSelect = (entry: OntologyEntry) => {
|
||||
setSelectedEntry((prev) => (prev?.uri === entry.uri ? null : entry));
|
||||
setRightPanel("none");
|
||||
};
|
||||
|
||||
const handleLoaded = () => {
|
||||
setShowLoader(false);
|
||||
fetchRegistry();
|
||||
};
|
||||
|
||||
const filteredEntries = entries.filter((e) => {
|
||||
if (statusFilter === "owl") return ["owl:Ontology"].includes(e.format) || e.format === "xml" || e.format === "turtle";
|
||||
if (statusFilter === "skos") return e.concept_count > 0;
|
||||
if (statusFilter === "internal") return e.status === "draft" || e.status === "published";
|
||||
if (statusFilter === "external") return e.status === "external";
|
||||
return true;
|
||||
});
|
||||
|
||||
const isSKOS = selectedEntry ? selectedEntry.concept_count > 0 : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showLoader && (
|
||||
<OntologyLoader
|
||||
onLoaded={handleLoaded}
|
||||
onClose={() => setShowLoader(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={shellStyle}>
|
||||
{/* Toolbar */}
|
||||
<div style={toolbarStyle}>
|
||||
<div style={searchBoxStyle}>
|
||||
<Search size={14} color="#6a7f97" />
|
||||
<input
|
||||
value={searchQ}
|
||||
onChange={(e) => setSearchQ(e.target.value)}
|
||||
placeholder="Search ontologies by name, URI, or namespace…"
|
||||
style={searchInputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{(["all", "owl", "skos", "internal", "external"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
style={{
|
||||
...filterPillBase,
|
||||
...(statusFilter === f ? filterPillActive : filterPillIdle),
|
||||
}}
|
||||
>
|
||||
{f === "all" ? "All" : f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
|
||||
<button
|
||||
onClick={() => setRightPanel((p) => (p === "search" ? "none" : "search"))}
|
||||
style={{
|
||||
...toolBtnStyle,
|
||||
...(rightPanel === "search" ? toolBtnActive : {}),
|
||||
}}
|
||||
>
|
||||
<Search size={13} />
|
||||
Entity Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLoader(true)}
|
||||
style={primaryToolBtnStyle}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Load Ontology
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionMsg && (
|
||||
<div
|
||||
style={{
|
||||
...actionMsgStyle,
|
||||
borderColor:
|
||||
actionMsg.type === "ok"
|
||||
? "rgba(76,195,138,0.22)"
|
||||
: "rgba(255,157,175,0.22)",
|
||||
background:
|
||||
actionMsg.type === "ok"
|
||||
? "rgba(76,195,138,0.06)"
|
||||
: "rgba(255,157,175,0.06)",
|
||||
color: actionMsg.type === "ok" ? "#4cc38a" : "#ff9daf",
|
||||
}}
|
||||
>
|
||||
{actionMsg.type === "ok" ? (
|
||||
<CheckCircle2 size={13} />
|
||||
) : (
|
||||
<AlertCircle size={13} />
|
||||
)}
|
||||
{actionMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div style={mainAreaStyle}>
|
||||
{/* Registry list */}
|
||||
<div style={listPanelStyle}>
|
||||
{loading ? (
|
||||
<div style={centerStyle}>
|
||||
<Loader2 size={22} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
|
||||
<span style={{ color: "#8fa8c6", fontSize: 13, marginTop: 10 }}>Loading registry…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={centerStyle}>
|
||||
<AlertCircle size={22} color="#ff9daf" />
|
||||
<span style={{ color: "#ff9daf", fontSize: 13, marginTop: 8 }}>{error}</span>
|
||||
<button onClick={fetchRegistry} style={retryBtnStyle}>Retry</button>
|
||||
</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div style={emptyStateStyle}>
|
||||
<GitMerge size={36} color="rgba(74,163,255,0.15)" />
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, marginTop: 12 }}>
|
||||
{searchQ ? "No ontologies match your search" : "No ontologies loaded yet"}
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
|
||||
Click <strong style={{ color: "#7fd0ff" }}>Load Ontology</strong> to import from a URL, upload a file, or create a new ontology.
|
||||
</div>
|
||||
<button onClick={() => setShowLoader(true)} style={{ ...primaryToolBtnStyle, marginTop: 16 }}>
|
||||
<Plus size={13} />
|
||||
Load Ontology
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={listStyle}>
|
||||
<div style={listHeaderStyle}>
|
||||
<span style={listHeaderTextStyle}>
|
||||
{filteredEntries.length} ontolog{filteredEntries.length === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
</div>
|
||||
{filteredEntries.map((entry) => (
|
||||
<RegistryRow
|
||||
key={entry.uri}
|
||||
entry={entry}
|
||||
selected={selectedEntry?.uri === entry.uri}
|
||||
onSelect={handleSelect}
|
||||
onToggle={handleToggle}
|
||||
onRefresh={handleRefresh}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
{rightPanel === "search" && (
|
||||
<div style={rightPanelStyle}>
|
||||
<div style={rightPanelHeaderStyle}>
|
||||
<span style={rightPanelTitleStyle}>Entity Search</span>
|
||||
<button onClick={() => setRightPanel("none")} style={closePanelBtnStyle}>×</button>
|
||||
</div>
|
||||
<OntologySearch />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPanel === "none" && selectedEntry && (
|
||||
<div style={rightPanelStyle}>
|
||||
<div style={rightPanelHeaderStyle}>
|
||||
<span style={rightPanelTitleStyle}>{selectedEntry.name}</span>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{isSKOS && (
|
||||
<button
|
||||
onClick={() => setRightPanel("skos")}
|
||||
style={browseBtnStyle}
|
||||
>
|
||||
<BookOpen size={12} />
|
||||
Browse SKOS
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setSelectedEntry(null)} style={closePanelBtnStyle}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={detailBodyStyle}>
|
||||
<DetailSection label="URI">
|
||||
<span style={{ fontFamily: "monospace", fontSize: 11, wordBreak: "break-all", color: "#c6d4e3" }}>
|
||||
{selectedEntry.uri}
|
||||
</span>
|
||||
</DetailSection>
|
||||
{selectedEntry.description && (
|
||||
<DetailSection label="Description">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{selectedEntry.description}
|
||||
</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
{selectedEntry.source_url && (
|
||||
<DetailSection label="Source URL">
|
||||
<a
|
||||
href={selectedEntry.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#58a6ff", fontSize: 11, wordBreak: "break-all" }}
|
||||
>
|
||||
{selectedEntry.source_url}
|
||||
</a>
|
||||
</DetailSection>
|
||||
)}
|
||||
{selectedEntry.version && (
|
||||
<DetailSection label="Version">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 12 }}>{selectedEntry.version}</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
{selectedEntry.loaded_at && (
|
||||
<DetailSection label="Loaded at">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 12 }}>
|
||||
{new Date(selectedEntry.loaded_at).toLocaleString()}
|
||||
</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
<div style={statRowStyle}>
|
||||
<StatBlock value={selectedEntry.class_count} label="Classes" color="#d2a8ff" />
|
||||
<StatBlock value={selectedEntry.concept_count} label="Concepts" color="#9ee8d7" />
|
||||
<StatBlock value={selectedEntry.property_count} label="Properties" color="#f2b66d" />
|
||||
</div>
|
||||
{selectedEntry.tags.length > 0 && (
|
||||
<DetailSection label="Tags">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{selectedEntry.tags.map((tag) => (
|
||||
<span key={tag} style={tagChipStyle}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPanel === "skos" && selectedEntry && (
|
||||
<div style={rightPanelStyle}>
|
||||
<div style={rightPanelHeaderStyle}>
|
||||
<span style={rightPanelTitleStyle}>SKOS — {selectedEntry.name}</span>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button onClick={() => setRightPanel("none")} style={browseBtnStyle}>
|
||||
<Layers size={12} />
|
||||
Registry Detail
|
||||
</button>
|
||||
<button onClick={() => setRightPanel("none")} style={closePanelBtnStyle}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<SKOSVocabularyManager schemeUri={selectedEntry.uri} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── sub-components ─────────────────────────────────────────────────── */
|
||||
|
||||
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.05)", paddingTop: 10, paddingBottom: 2 }}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 4 }}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBlock({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 2, padding: "10px 6px", background: "rgba(255,255,255,0.02)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<span style={{ color, fontSize: 18, fontWeight: 800 }}>{value.toLocaleString()}</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0a1525",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const toolbarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "12px 18px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(5,12,22,0.72)",
|
||||
flexWrap: "wrap",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const searchBoxStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(127,208,255,0.14)",
|
||||
background: "rgba(0,0,0,0.24)",
|
||||
flex: "0 0 280px",
|
||||
};
|
||||
|
||||
const searchInputStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "#ebf3ff",
|
||||
fontSize: 12,
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const filterPillBase: React.CSSProperties = {
|
||||
padding: "5px 11px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid transparent",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const filterPillIdle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
color: "#8fa8c6",
|
||||
borderColor: "rgba(127,208,255,0.1)",
|
||||
};
|
||||
|
||||
const filterPillActive: React.CSSProperties = {
|
||||
background: "rgba(74,163,255,0.14)",
|
||||
color: "#ebf3ff",
|
||||
borderColor: "rgba(127,208,255,0.26)",
|
||||
};
|
||||
|
||||
const toolBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.16)",
|
||||
background: "rgba(74,163,255,0.06)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const toolBtnActive: React.CSSProperties = {
|
||||
background: "rgba(74,163,255,0.16)",
|
||||
color: "#ebf3ff",
|
||||
borderColor: "rgba(127,208,255,0.28)",
|
||||
};
|
||||
|
||||
const primaryToolBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "7px 14px",
|
||||
borderRadius: 9,
|
||||
border: "1px solid rgba(74,163,255,0.3)",
|
||||
background: "linear-gradient(135deg, rgba(74,163,255,0.2), rgba(74,163,255,0.08))",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const actionMsgStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 18px",
|
||||
fontSize: 12,
|
||||
borderBottom: "1px solid",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const mainAreaStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const listPanelStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflowY: "auto",
|
||||
borderRight: "1px solid rgba(127,208,255,0.08)",
|
||||
};
|
||||
|
||||
const listStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "12px 14px",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const listHeaderStyle: React.CSSProperties = {
|
||||
paddingBottom: 6,
|
||||
};
|
||||
|
||||
const listHeaderTextStyle: React.CSSProperties = {
|
||||
color: "#6a7f97",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "12px 14px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid",
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const rowMainStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const rowNameStyle: React.CSSProperties = {
|
||||
color: "#ebf3ff",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const rowUriStyle: React.CSSProperties = {
|
||||
color: "#6a7f97",
|
||||
fontSize: 11,
|
||||
fontFamily: "monospace",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const sourceLinkStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
color: "#58a6ff",
|
||||
fontSize: 11,
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
const rowStatsStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const rowActionsStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const actionBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 5,
|
||||
borderRadius: 6,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#8fa8c6",
|
||||
};
|
||||
|
||||
const rightPanelStyle: React.CSSProperties = {
|
||||
width: 360,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderLeft: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(5,12,22,0.6)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const rightPanelHeaderStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "14px 16px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const rightPanelTitleStyle: React.CSSProperties = {
|
||||
color: "#ebf3ff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const closePanelBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
lineHeight: 1,
|
||||
padding: "0 2px",
|
||||
};
|
||||
|
||||
const browseBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 7,
|
||||
border: "1px solid rgba(127,208,255,0.18)",
|
||||
background: "rgba(74,163,255,0.06)",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const detailBodyStyle: React.CSSProperties = {
|
||||
padding: "14px 16px",
|
||||
overflowY: "auto",
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0,
|
||||
};
|
||||
|
||||
const statRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const tagChipStyle: React.CSSProperties = {
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const disabledBadgeStyle: React.CSSProperties = {
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: "rgba(106,127,151,0.12)",
|
||||
border: "1px solid rgba(106,127,151,0.2)",
|
||||
color: "#6a7f97",
|
||||
};
|
||||
|
||||
const centerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
padding: 40,
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
...centerStyle,
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
const retryBtnStyle: React.CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "6px 14px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.18)",
|
||||
background: "transparent",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
};
|
||||
@@ -0,0 +1,574 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SearchResult {
|
||||
uri: string;
|
||||
label: string;
|
||||
type: string;
|
||||
entity_type: string;
|
||||
definition?: string;
|
||||
source_ontology?: string;
|
||||
namespace_prefix?: string;
|
||||
}
|
||||
|
||||
interface EntityDetail {
|
||||
uri: string;
|
||||
label: string;
|
||||
type: string;
|
||||
entity_type: string;
|
||||
definition?: string;
|
||||
source_ontology?: string;
|
||||
superclasses: string[];
|
||||
subclasses: string[];
|
||||
domain: string[];
|
||||
range: string[];
|
||||
instance_count: number;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_COLORS: Record<string, string> = {
|
||||
class: "#d2a8ff",
|
||||
property: "#f2b66d",
|
||||
individual: "#9ee8d7",
|
||||
concept: "#58a6ff",
|
||||
scheme: "#7fd0ff",
|
||||
unknown: "#6a7f97",
|
||||
};
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
class: "Class",
|
||||
property: "Property",
|
||||
individual: "Individual",
|
||||
concept: "Concept",
|
||||
scheme: "Scheme",
|
||||
unknown: "Entity",
|
||||
};
|
||||
|
||||
function TypeBadge({ entityType }: { entityType: string }) {
|
||||
const color = ENTITY_TYPE_COLORS[entityType] || ENTITY_TYPE_COLORS.unknown;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "1px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase" as const,
|
||||
background: `${color}14`,
|
||||
border: `1px solid ${color}28`,
|
||||
color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{ENTITY_TYPE_LABELS[entityType] || entityType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UriRef({ uri }: { uri: string }) {
|
||||
const short = uri.includes("#")
|
||||
? uri.split("#").pop() || uri
|
||||
: uri.split("/").pop() || uri;
|
||||
return (
|
||||
<span
|
||||
title={uri}
|
||||
style={{ color: "#58a6ff", fontSize: 11, fontFamily: "monospace", cursor: "help" }}
|
||||
>
|
||||
{short}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
result,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
result: SearchResult;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid",
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
background: selected ? "rgba(74,163,255,0.1)" : "rgba(255,255,255,0.02)",
|
||||
borderColor: selected ? "rgba(127,208,255,0.24)" : "rgba(127,208,255,0.08)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{result.label || result.uri}
|
||||
</span>
|
||||
<TypeBadge entityType={result.entity_type} />
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, fontFamily: "monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{result.uri}
|
||||
</div>
|
||||
{result.definition && (
|
||||
<div style={{ color: "#8fa8c6", fontSize: 12, lineHeight: 1.4, overflow: "hidden", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" as const }}>
|
||||
{result.definition}
|
||||
</div>
|
||||
)}
|
||||
{result.source_ontology && (
|
||||
<div style={{ color: "#5a7a9a", fontSize: 10 }}>
|
||||
From: {result.source_ontology}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleList({ label, items }: { label: string; items: string[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={collapseHdrStyle}
|
||||
>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>{label}</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>({items.length})</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginLeft: 16, marginTop: 4, display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
{items.slice(0, 12).map((uri) => (
|
||||
<div key={uri} style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>→</span>
|
||||
<UriRef uri={uri} />
|
||||
</div>
|
||||
))}
|
||||
{items.length > 12 && (
|
||||
<span style={{ color: "#5a7a9a", fontSize: 10 }}>+{items.length - 12} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailPanel({
|
||||
uri,
|
||||
onClose,
|
||||
}: {
|
||||
uri: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<EntityDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("Not found");
|
||||
return r.json();
|
||||
})
|
||||
.then(setDetail)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [uri]);
|
||||
|
||||
return (
|
||||
<div style={detailPanelStyle}>
|
||||
<div style={detailHeaderStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<BookOpen size={14} color="#d2a8ff" />
|
||||
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
Entity Detail
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeDetailBtnStyle}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={centerStyle}>
|
||||
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={centerStyle}>
|
||||
<AlertCircle size={16} color="#ff9daf" />
|
||||
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && !loading && (
|
||||
<div style={detailBodyStyle}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", marginBottom: 4 }}>
|
||||
<h3 style={{ margin: 0, color: "#ebf3ff", fontSize: 17, fontWeight: 800, letterSpacing: "-0.03em" }}>
|
||||
{detail.label || detail.uri.split("/").pop()}
|
||||
</h3>
|
||||
<TypeBadge entityType={detail.entity_type} />
|
||||
</div>
|
||||
<div style={{ color: "#5a7a9a", fontSize: 10, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{detail.uri}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.definition && (
|
||||
<DetailSection label="Definition">
|
||||
<p style={{ margin: 0, color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{detail.definition}
|
||||
</p>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{detail.instance_count > 0 && (
|
||||
<DetailSection label="Instances">
|
||||
<span style={{ color: "#9ee8d7", fontSize: 14, fontWeight: 800 }}>
|
||||
{detail.instance_count.toLocaleString()}
|
||||
</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
<CollapsibleList label="Superclasses / Broader" items={detail.superclasses} />
|
||||
<CollapsibleList label="Subclasses / Narrower" items={detail.subclasses} />
|
||||
<CollapsibleList label="Domain" items={detail.domain} />
|
||||
<CollapsibleList label="Range" items={detail.range} />
|
||||
|
||||
{detail.source_ontology && (
|
||||
<DetailSection label="Source Ontology">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 12, fontFamily: "monospace" }}>
|
||||
{detail.source_ontology}
|
||||
</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={detail.uri}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={openUriStyle}
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
Open URI
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ paddingTop: 10, borderTop: "1px solid rgba(255,255,255,0.05)", marginTop: 10 }}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 5 }}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main OntologySearch component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function OntologySearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [entityType, setEntityType] = useState<string>("all");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedUri, setSelectedUri] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const runSearch = async (q: string, type: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ q: q.trim(), limit: "80" });
|
||||
if (type !== "all") params.set("entity_type", type);
|
||||
const res = await fetch(`/api/ontology/search?${params}`);
|
||||
if (!res.ok) throw new Error("Search failed");
|
||||
setResults(await res.json());
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => runSearch(query, entityType), 320);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}, [query, entityType]);
|
||||
|
||||
return (
|
||||
<div style={searchShellStyle}>
|
||||
{/* Search input */}
|
||||
<div style={searchTopStyle}>
|
||||
<div style={searchBarStyle}>
|
||||
<Search size={14} color="#6a7f97" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search classes, properties, concepts…"
|
||||
style={searchInputStyle}
|
||||
/>
|
||||
{searching && <Loader2 size={13} color="#4aa3ff" style={{ animation: "spin 0.8s linear infinite", flexShrink: 0 }} />}
|
||||
{query && !searching && (
|
||||
<button onClick={() => { setQuery(""); setResults([]); }} style={clearBtnStyle}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={typeFilterStyle}>
|
||||
{(["all", "class", "property", "individual", "concept", "scheme"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setEntityType(t)}
|
||||
style={{
|
||||
...typeFilterBtnBase,
|
||||
...(entityType === t ? typeFilterBtnActive : typeFilterBtnIdle),
|
||||
}}
|
||||
>
|
||||
{t === "all" ? "All" : ENTITY_TYPE_LABELS[t] || t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results + detail */}
|
||||
<div style={searchBodyStyle}>
|
||||
<div style={resultListStyle}>
|
||||
{!query && (
|
||||
<div style={hintStyle}>
|
||||
<Search size={20} color="rgba(74,163,255,0.2)" />
|
||||
<span style={{ color: "#6a7f97", fontSize: 12, marginTop: 8 }}>
|
||||
Type to search across all loaded ontologies
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query && results.length === 0 && !searching && (
|
||||
<div style={hintStyle}>
|
||||
<span style={{ color: "#6a7f97", fontSize: 12 }}>No results for "{query}"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div style={{ padding: "10px 12px", display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, fontWeight: 700, marginBottom: 2 }}>
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
{results.map((r) => (
|
||||
<ResultRow
|
||||
key={r.uri}
|
||||
result={r}
|
||||
selected={selectedUri === r.uri}
|
||||
onSelect={() => setSelectedUri((prev) => (prev === r.uri ? null : r.uri))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedUri && (
|
||||
<DetailPanel uri={selectedUri} onClose={() => setSelectedUri(null)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const searchShellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const searchTopStyle: React.CSSProperties = {
|
||||
padding: "12px 14px 10px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.08)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const searchBarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(127,208,255,0.14)",
|
||||
background: "rgba(0,0,0,0.24)",
|
||||
};
|
||||
|
||||
const searchInputStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "#ebf3ff",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const clearBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#6a7f97",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
};
|
||||
|
||||
const typeFilterStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 5,
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const typeFilterBtnBase: React.CSSProperties = {
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid transparent",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const typeFilterBtnIdle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
color: "#8fa8c6",
|
||||
borderColor: "rgba(127,208,255,0.1)",
|
||||
};
|
||||
|
||||
const typeFilterBtnActive: React.CSSProperties = {
|
||||
background: "rgba(74,163,255,0.14)",
|
||||
color: "#ebf3ff",
|
||||
borderColor: "rgba(127,208,255,0.26)",
|
||||
};
|
||||
|
||||
const searchBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const resultListStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
minWidth: 0,
|
||||
};
|
||||
|
||||
const hintStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
padding: 32,
|
||||
};
|
||||
|
||||
const detailPanelStyle: React.CSSProperties = {
|
||||
width: 320,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderLeft: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(3,9,18,0.5)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const detailHeaderStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 14px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.08)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const closeDetailBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
};
|
||||
|
||||
const detailBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "14px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0,
|
||||
};
|
||||
|
||||
const centerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
padding: 24,
|
||||
};
|
||||
|
||||
const collapseHdrStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
padding: "6px 0",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
};
|
||||
|
||||
const openUriStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
marginTop: 14,
|
||||
color: "#58a6ff",
|
||||
fontSize: 11,
|
||||
textDecoration: "none",
|
||||
};
|
||||
@@ -0,0 +1,638 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SKOSScheme {
|
||||
uri: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
concept_count: number;
|
||||
}
|
||||
|
||||
interface ConceptNode {
|
||||
uri: string;
|
||||
pref_label: string;
|
||||
alt_labels?: string[];
|
||||
description?: string;
|
||||
notation?: string;
|
||||
scheme_uri?: string;
|
||||
parent_uri?: string;
|
||||
children?: ConceptNode[];
|
||||
}
|
||||
|
||||
interface SKOSConceptDetail {
|
||||
uri: string;
|
||||
pref_label: string;
|
||||
alt_labels: string[];
|
||||
hidden_labels: string[];
|
||||
definition?: string;
|
||||
scope_note?: string;
|
||||
editorial_note?: string;
|
||||
broader: string[];
|
||||
narrower: string[];
|
||||
related: string[];
|
||||
exact_match: string[];
|
||||
close_match: string[];
|
||||
broad_match: string[];
|
||||
narrow_match: string[];
|
||||
scheme_uri?: string;
|
||||
}
|
||||
|
||||
function countConcepts(nodes: ConceptNode[]): number {
|
||||
return nodes.reduce((acc, n) => acc + 1 + countConcepts(n.children ?? []), 0);
|
||||
}
|
||||
|
||||
function LabelChip({ label }: { label: string }) {
|
||||
return (
|
||||
<span style={chipStyle}>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UriLink({ uri }: { uri: string }) {
|
||||
const short = uri.includes("#") ? uri.split("#").pop() : uri.split("/").pop();
|
||||
return (
|
||||
<span title={uri} style={{ color: "#58a6ff", fontSize: 11, fontFamily: "monospace", cursor: "help" }}>
|
||||
{short || uri}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConceptDetailPanel({
|
||||
uri,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: {
|
||||
uri: string;
|
||||
onClose: () => void;
|
||||
onNavigate: (uri: string) => void;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<SKOSConceptDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetch(`/api/ontology/skos/concept/${encodeURIComponent(uri)}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("Concept not found");
|
||||
return r.json();
|
||||
})
|
||||
.then(setDetail)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [uri]);
|
||||
|
||||
const renderUriList = (label: string, uris: string[]) => {
|
||||
if (!uris.length) return null;
|
||||
return (
|
||||
<PropSection label={label}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{uris.map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
onClick={() => onNavigate(u)}
|
||||
style={navLinkStyle}
|
||||
>
|
||||
<ChevronRight size={10} />
|
||||
<UriLink uri={u} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PropSection>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={detailPanelStyle}>
|
||||
<div style={detailHeaderStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<BookOpen size={13} color="#9ee8d7" />
|
||||
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700 }}>Concept Detail</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={iconBtnStyle}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={centerStyle}>
|
||||
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={centerStyle}>
|
||||
<AlertCircle size={16} color="#ff9daf" />
|
||||
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && !loading && (
|
||||
<div style={detailBodyStyle}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<h3 style={{ margin: "0 0 4px", color: "#ebf3ff", fontSize: 17, fontWeight: 800, letterSpacing: "-0.03em" }}>
|
||||
{detail.pref_label}
|
||||
</h3>
|
||||
{detail.alt_labels.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 6 }}>
|
||||
{detail.alt_labels.map((l) => <LabelChip key={l} label={l} />)}
|
||||
</div>
|
||||
)}
|
||||
{detail.hidden_labels.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 6 }}>
|
||||
{detail.hidden_labels.map((l) => (
|
||||
<span key={l} style={{ ...chipStyle, opacity: 0.5, fontStyle: "italic" }}>{l}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: "#5a7a9a", fontSize: 10, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{uri}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.definition && (
|
||||
<PropSection label="Definition">
|
||||
<p style={{ margin: 0, color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{detail.definition}
|
||||
</p>
|
||||
</PropSection>
|
||||
)}
|
||||
|
||||
{detail.scope_note && (
|
||||
<PropSection label="Scope Note">
|
||||
<p style={{ margin: 0, color: "#8fa8c6", fontSize: 12, lineHeight: 1.5 }}>
|
||||
{detail.scope_note}
|
||||
</p>
|
||||
</PropSection>
|
||||
)}
|
||||
|
||||
{detail.editorial_note && (
|
||||
<PropSection label="Editorial Note">
|
||||
<p style={{ margin: 0, color: "#8fa8c6", fontSize: 12, lineHeight: 1.5 }}>
|
||||
{detail.editorial_note}
|
||||
</p>
|
||||
</PropSection>
|
||||
)}
|
||||
|
||||
{renderUriList("Broader", detail.broader)}
|
||||
{renderUriList("Narrower", detail.narrower)}
|
||||
{renderUriList("Related", detail.related)}
|
||||
{renderUriList("Exact Match", detail.exact_match)}
|
||||
{renderUriList("Close Match", detail.close_match)}
|
||||
{renderUriList("Broad Match", detail.broad_match)}
|
||||
{renderUriList("Narrow Match", detail.narrow_match)}
|
||||
|
||||
{detail.scheme_uri && (
|
||||
<PropSection label="Concept Scheme">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 11, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{detail.scheme_uri}
|
||||
</span>
|
||||
</PropSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ paddingTop: 10, borderTop: "1px solid rgba(255,255,255,0.05)", marginTop: 10 }}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 5 }}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concept tree node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConceptTreeNode({
|
||||
concept,
|
||||
depth,
|
||||
selectedUri,
|
||||
onSelect,
|
||||
}: {
|
||||
concept: ConceptNode;
|
||||
depth: number;
|
||||
selectedUri: string | null;
|
||||
onSelect: (uri: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const children = concept.children ?? [];
|
||||
const hasChildren = children.length > 0;
|
||||
const isSelected = selectedUri === concept.uri;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingLeft: 10 + depth * 14,
|
||||
paddingRight: 10,
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
borderRadius: 7,
|
||||
cursor: "pointer",
|
||||
background: isSelected ? "rgba(74,163,255,0.12)" : "transparent",
|
||||
transition: "120ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected)
|
||||
(e.currentTarget as HTMLDivElement).style.background = "rgba(74,163,255,0.06)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected)
|
||||
(e.currentTarget as HTMLDivElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
|
||||
style={expandBtnStyle}
|
||||
>
|
||||
{expanded ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ width: 18, display: "inline-block", flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<span
|
||||
onClick={() => onSelect(concept.uri)}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: isSelected ? "#ebf3ff" : depth === 0 ? "#c6d4e3" : "#8fa8c6",
|
||||
fontSize: depth === 0 ? 13 : 12,
|
||||
fontWeight: depth === 0 ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{concept.pref_label || concept.uri}
|
||||
</span>
|
||||
|
||||
{hasChildren && (
|
||||
<span style={{ color: "#5a7a9a", fontSize: 10, flexShrink: 0 }}>
|
||||
{children.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && hasChildren && children.map((child) => (
|
||||
<ConceptTreeNode
|
||||
key={child.uri}
|
||||
concept={child}
|
||||
depth={depth + 1}
|
||||
selectedUri={selectedUri}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheme panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SchemePanel({
|
||||
scheme,
|
||||
selectedUri,
|
||||
onSelectConcept,
|
||||
searchQuery,
|
||||
}: {
|
||||
scheme: SKOSScheme;
|
||||
selectedUri: string | null;
|
||||
onSelectConcept: (uri: string) => void;
|
||||
searchQuery: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [hierarchy, setHierarchy] = useState<ConceptNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
setLoading(true);
|
||||
fetch(`/api/vocabulary/hierarchy?scheme=${encodeURIComponent(scheme.uri)}`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setHierarchy)
|
||||
.catch(() => setHierarchy([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [scheme.uri, expanded]);
|
||||
|
||||
const totalConcepts = countConcepts(hierarchy);
|
||||
|
||||
const filterConcepts = (nodes: ConceptNode[], q: string): ConceptNode[] => {
|
||||
if (!q) return nodes;
|
||||
return nodes.flatMap((n) => {
|
||||
const match = (n.pref_label + " " + (n.alt_labels?.join(" ") ?? "") + " " + (n.description ?? ""))
|
||||
.toLowerCase()
|
||||
.includes(q.toLowerCase());
|
||||
const filteredChildren = filterConcepts(n.children ?? [], q);
|
||||
if (match || filteredChildren.length > 0) {
|
||||
return [{ ...n, children: filteredChildren }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const displayedConcepts = filterConcepts(hierarchy, searchQuery);
|
||||
|
||||
return (
|
||||
<div style={schemePanelStyle}>
|
||||
<button onClick={() => setExpanded((v) => !v)} style={schemeHeaderBtnStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{expanded ? <ChevronDown size={13} color="#8fa8c6" /> : <ChevronRight size={13} color="#8fa8c6" />}
|
||||
<span style={{ color: "#e6edf3", fontSize: 14, fontWeight: 700 }}>{scheme.title}</span>
|
||||
</div>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>
|
||||
{loading ? "…" : `${totalConcepts} concept${totalConcepts !== 1 ? "s" : ""}`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: "10px 20px", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Loader2 size={12} color="#4aa3ff" style={{ animation: "spin 0.8s linear infinite" }} />
|
||||
<span style={{ color: "#6a7f97", fontSize: 12 }}>Loading concepts…</span>
|
||||
</div>
|
||||
) : displayedConcepts.length === 0 ? (
|
||||
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12, fontStyle: "italic" }}>
|
||||
{searchQuery ? "No matching concepts" : "No concepts in this scheme"}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ paddingTop: 2 }}>
|
||||
{displayedConcepts.map((concept) => (
|
||||
<ConceptTreeNode
|
||||
key={concept.uri}
|
||||
concept={concept}
|
||||
depth={0}
|
||||
selectedUri={selectedUri}
|
||||
onSelect={onSelectConcept}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main SKOSVocabularyManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Props {
|
||||
schemeUri?: string;
|
||||
}
|
||||
|
||||
export function SKOSVocabularyManager({ schemeUri }: Props) {
|
||||
const [schemes, setSchemes] = useState<SKOSScheme[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQ, setSearchQ] = useState("");
|
||||
const [selectedUri, setSelectedUri] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/ontology/skos/schemes")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setSchemes)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const displayedSchemes = schemeUri
|
||||
? schemes.filter((s) => s.uri === schemeUri)
|
||||
: schemes;
|
||||
|
||||
return (
|
||||
<div style={managerShellStyle}>
|
||||
{/* Search bar */}
|
||||
<div style={skosToolbarStyle}>
|
||||
<div style={skosSearchBarStyle}>
|
||||
<Search size={13} color="#6a7f97" />
|
||||
<input
|
||||
value={searchQ}
|
||||
onChange={(e) => setSearchQ(e.target.value)}
|
||||
placeholder="Search labels and definitions…"
|
||||
style={skosSearchInputStyle}
|
||||
/>
|
||||
{searchQ && (
|
||||
<button onClick={() => setSearchQ("")} style={iconBtnStyle}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={skosBodyStyle}>
|
||||
{/* Scheme tree column */}
|
||||
<div style={treeColStyle}>
|
||||
{loading && (
|
||||
<div style={centerStyle}>
|
||||
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={centerStyle}>
|
||||
<AlertCircle size={16} color="#ff9daf" />
|
||||
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && displayedSchemes.length === 0 && (
|
||||
<div style={{ ...centerStyle, textAlign: "center", padding: 28 }}>
|
||||
<BookOpen size={28} color="rgba(158,232,215,0.15)" />
|
||||
<span style={{ color: "#8fa8c6", fontSize: 12, marginTop: 10 }}>
|
||||
No SKOS concept schemes found
|
||||
</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11, marginTop: 4, maxWidth: 220 }}>
|
||||
Import a SKOS vocabulary to browse concepts here
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && displayedSchemes.map((scheme) => (
|
||||
<SchemePanel
|
||||
key={scheme.uri}
|
||||
scheme={scheme}
|
||||
selectedUri={selectedUri}
|
||||
onSelectConcept={setSelectedUri}
|
||||
searchQuery={searchQ}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Concept detail panel */}
|
||||
{selectedUri && (
|
||||
<ConceptDetailPanel
|
||||
uri={selectedUri}
|
||||
onClose={() => setSelectedUri(null)}
|
||||
onNavigate={setSelectedUri}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const managerShellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const skosToolbarStyle: React.CSSProperties = {
|
||||
padding: "10px 12px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.08)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const skosSearchBarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "6px 10px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.12)",
|
||||
background: "rgba(0,0,0,0.22)",
|
||||
};
|
||||
|
||||
const skosSearchInputStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "#ebf3ff",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const skosBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const treeColStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "8px 6px",
|
||||
};
|
||||
|
||||
const detailPanelStyle: React.CSSProperties = {
|
||||
width: 300,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderLeft: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(3,9,18,0.5)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const detailHeaderStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 14px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.08)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const detailBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "14px",
|
||||
};
|
||||
|
||||
const schemePanelStyle: React.CSSProperties = {
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
overflow: "hidden",
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
const schemeHeaderBtnStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 12px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const expandBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
width: 18,
|
||||
};
|
||||
|
||||
const chipStyle: React.CSSProperties = {
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const iconBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
};
|
||||
|
||||
const navLinkStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "2px 0",
|
||||
textAlign: "left",
|
||||
};
|
||||
|
||||
const centerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
padding: 24,
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
BookMarked,
|
||||
GitMerge,
|
||||
HeartPulse,
|
||||
Layers,
|
||||
Shield,
|
||||
Sliders,
|
||||
} from "lucide-react";
|
||||
import { OntologyManager } from "./OntologyManager";
|
||||
|
||||
export type OntologyHubTab =
|
||||
| "registry"
|
||||
| "editor"
|
||||
| "versions"
|
||||
| "alignments"
|
||||
| "health"
|
||||
| "shacl";
|
||||
|
||||
const TAB_PARAM = "ontologyTab";
|
||||
|
||||
const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
|
||||
{ id: "registry", label: "Registry", icon: BookMarked },
|
||||
{ id: "editor", label: "Editor", icon: Sliders },
|
||||
{ id: "versions", label: "Versions", icon: Layers },
|
||||
{ id: "alignments", label: "Alignments", icon: GitMerge },
|
||||
{ id: "health", label: "Health", icon: HeartPulse },
|
||||
{ id: "shacl", label: "SHACL", icon: Shield },
|
||||
];
|
||||
|
||||
function readTabParam(): OntologyHubTab {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(TAB_PARAM);
|
||||
if (raw && TABS.some((t) => t.id === raw)) return raw as OntologyHubTab;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "registry";
|
||||
}
|
||||
|
||||
function writeTabParam(tab: OntologyHubTab) {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(TAB_PARAM, tab);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function ComingSoonStub({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
}: {
|
||||
icon: typeof GitMerge;
|
||||
title: string;
|
||||
description: string;
|
||||
badge: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={stubShellStyle}>
|
||||
<div style={stubCardStyle}>
|
||||
<div style={stubIconRingStyle}>
|
||||
<Icon size={28} color="#7fd0ff" />
|
||||
</div>
|
||||
<div style={stubBadgeStyle}>{badge}</div>
|
||||
<h2 style={stubTitleStyle}>{title}</h2>
|
||||
<p style={stubDescStyle}>{description}</p>
|
||||
<div style={stubDividerStyle} />
|
||||
<p style={stubSubnoteStyle}>Coming in Subissue 2 / 3 of Ontology Hub</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OntologyWorkspace() {
|
||||
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readTabParam);
|
||||
|
||||
useEffect(() => {
|
||||
writeTabParam(activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleTabChange = useCallback((tab: OntologyHubTab) => {
|
||||
setActiveTab(tab);
|
||||
}, []);
|
||||
|
||||
const renderTab = () => {
|
||||
switch (activeTab) {
|
||||
case "registry":
|
||||
return <OntologyManager />;
|
||||
case "editor":
|
||||
return (
|
||||
<ComingSoonStub
|
||||
icon={Sliders}
|
||||
title="Visual Ontology Editor"
|
||||
description="Visually edit classes, properties, individuals, restrictions, axioms, and SKOS metadata. Create and propose schema changes through a governed draft workflow."
|
||||
badge="Subissue 2"
|
||||
/>
|
||||
);
|
||||
case "versions":
|
||||
return (
|
||||
<ComingSoonStub
|
||||
icon={Layers}
|
||||
title="Versions & Change Proposals"
|
||||
description="View version history, compare schema diffs, submit change proposals, and manage the review-to-publish lifecycle."
|
||||
badge="Subissue 2"
|
||||
/>
|
||||
);
|
||||
case "alignments":
|
||||
return (
|
||||
<ComingSoonStub
|
||||
icon={GitMerge}
|
||||
title="Cross-Ontology Alignments"
|
||||
description="Manage mappings between ontologies, review suggested alignments from embedding-assisted similarity, and publish alignment sets."
|
||||
badge="Subissue 3"
|
||||
/>
|
||||
);
|
||||
case "health":
|
||||
return (
|
||||
<ComingSoonStub
|
||||
icon={HeartPulse}
|
||||
title="Ontology Health Dashboard"
|
||||
description="Score completeness, consistency, SHACL conformance, alignment coverage, and documentation quality across all loaded ontologies."
|
||||
badge="Subissue 3"
|
||||
/>
|
||||
);
|
||||
case "shacl":
|
||||
return (
|
||||
<ComingSoonStub
|
||||
icon={Shield}
|
||||
title="SHACL Studio"
|
||||
description="Generate, edit, and validate SHACL shapes. Preview constraint violations against the active graph before publishing."
|
||||
badge="Subissue 3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
<div style={tabBarStyle}>
|
||||
{TABS.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
style={{
|
||||
...tabBtnBase,
|
||||
...(activeTab === id ? tabBtnActive : tabBtnIdle),
|
||||
}}
|
||||
onClick={() => handleTabChange(id)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={contentStyle}>{renderTab()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#07111f",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const tabBarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
padding: "10px 18px",
|
||||
borderBottom: "1px solid rgba(140,192,255,0.12)",
|
||||
background: "rgba(3,9,18,0.72)",
|
||||
flexShrink: 0,
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const tabBtnBase: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 13px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
transition: "160ms ease",
|
||||
background: "transparent",
|
||||
};
|
||||
|
||||
const tabBtnIdle: React.CSSProperties = {
|
||||
color: "#8fa8c6",
|
||||
borderColor: "rgba(127,208,255,0.1)",
|
||||
};
|
||||
|
||||
const tabBtnActive: React.CSSProperties = {
|
||||
color: "#ebf3ff",
|
||||
background: "rgba(74,163,255,0.16)",
|
||||
borderColor: "rgba(127,208,255,0.3)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const stubShellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "linear-gradient(180deg, rgba(7,17,31,0.8), rgba(5,11,21,0.95))",
|
||||
};
|
||||
|
||||
const stubCardStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "48px 52px",
|
||||
borderRadius: 28,
|
||||
border: "1px solid rgba(127,208,255,0.12)",
|
||||
background: "rgba(9,19,34,0.82)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.06)",
|
||||
maxWidth: 480,
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
const stubIconRingStyle: React.CSSProperties = {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: "50%",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "rgba(74,163,255,0.1)",
|
||||
border: "1px solid rgba(127,208,255,0.18)",
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const stubBadgeStyle: React.CSSProperties = {
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(242,182,109,0.1)",
|
||||
border: "1px solid rgba(242,182,109,0.22)",
|
||||
color: "#f2b66d",
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const stubTitleStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#ebf3ff",
|
||||
fontSize: 22,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const stubDescStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#8fa8c6",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.65,
|
||||
maxWidth: 360,
|
||||
};
|
||||
|
||||
const stubDividerStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
background: "rgba(127,208,255,0.08)",
|
||||
};
|
||||
|
||||
const stubSubnoteStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#5a7a9a",
|
||||
fontSize: 12,
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
base: '/',
|
||||
@@ -0,0 +1,137 @@
|
||||
# Semantica × OpenClaw Integration
|
||||
|
||||
Connect [OpenClaw](https://openclaw.ai) — the open-source personal AI agent — to Semantica's full knowledge-graph and decision-intelligence stack.
|
||||
|
||||
Two integration paths are available:
|
||||
|
||||
| Path | When to use |
|
||||
|---|---|
|
||||
| **MCP (recommended)** | OpenClaw Gateway is running; zero extra code needed |
|
||||
| **REST / native tool** | Embedding Semantica directly in a SOUL.md agent config |
|
||||
|
||||
---
|
||||
|
||||
## Path 1 — MCP Server (recommended)
|
||||
|
||||
### 1. Start the Semantica MCP server
|
||||
|
||||
```bash
|
||||
python -m semantica.mcp_server
|
||||
```
|
||||
|
||||
### 2. Add to `mcporter.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "semantica.mcp_server"],
|
||||
"transport": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Restart the OpenClaw Gateway
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
All **12 Semantica tools** are now available to any OpenClaw agent:
|
||||
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `extract_entities` | Named entity recognition from text |
|
||||
| `extract_relations` | Relation / triplet extraction from text |
|
||||
| `record_decision` | Record a decision with causal links |
|
||||
| `query_decisions` | Search recorded decisions |
|
||||
| `find_precedents` | Find past decisions similar to a query |
|
||||
| `get_causal_chain` | Trace cause-effect chains from a node |
|
||||
| `add_entity` | Add a node to the knowledge graph |
|
||||
| `add_relationship` | Add an edge between two nodes |
|
||||
| `run_reasoning` | Forward-chain rules over facts |
|
||||
| `get_graph_analytics` | Centrality, communities, topology stats |
|
||||
| `export_graph` | Export graph (JSON, RDF, GraphML, …) |
|
||||
| `get_graph_summary` | High-level graph overview |
|
||||
|
||||
**3 resources** are also exposed: `semantica://graph/summary`, `semantica://decisions/list`, `semantica://schema/info`.
|
||||
|
||||
---
|
||||
|
||||
## Path 2 — Native Tool (REST)
|
||||
|
||||
Use `OpenClawKGTool` when you prefer a direct Python integration without the MCP gateway.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
pip install semantica[openclaw] # pulls in 'requests'
|
||||
```
|
||||
|
||||
### Quick start
|
||||
|
||||
```python
|
||||
from integrations.openclaw import OpenClawKGTool
|
||||
|
||||
tool = OpenClawKGTool(base_url="http://localhost:8000")
|
||||
|
||||
# Extract knowledge from text
|
||||
entities = tool.extract_entities("OpenClaw is an open-source AI agent built in Python.")
|
||||
relations = tool.extract_relations("Alice manages the OpenClaw project at Hawksight.")
|
||||
|
||||
# Record and query decisions
|
||||
tool.record_decision("Deploy model v2 to production", context="latency improved by 40%")
|
||||
precedents = tool.find_precedents("roll back production deployment")
|
||||
|
||||
# Graph analytics
|
||||
summary = tool.get_graph_summary()
|
||||
analytics = tool.get_graph_analytics()
|
||||
|
||||
# Export
|
||||
ttl = tool.export_graph(fmt="ttl")
|
||||
```
|
||||
|
||||
### Generate `mcporter.json` programmatically
|
||||
|
||||
```python
|
||||
from integrations.openclaw import OpenClawMCPConfig
|
||||
|
||||
cfg = OpenClawMCPConfig()
|
||||
print(cfg.to_json()) # → paste into mcporter.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SOUL.md agent snippet
|
||||
|
||||
Add Semantica to any OpenClaw agent by referencing the tool in your `SOUL.md`:
|
||||
|
||||
```markdown
|
||||
## Tools
|
||||
|
||||
- name: semantica_kg
|
||||
description: >
|
||||
Semantica knowledge-graph tool. Supports entity extraction, decision
|
||||
recording, graph querying, causal chain analysis, reasoning, and
|
||||
multi-format export.
|
||||
endpoint: http://localhost:8000
|
||||
auth: none
|
||||
|
||||
## Instructions
|
||||
|
||||
You have access to `semantica_kg`. Use it to:
|
||||
- Extract entities and relations from any text the user provides.
|
||||
- Record important decisions and retrieve precedents before recommending actions.
|
||||
- Run graph analytics and export results when the user asks for a summary.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- `pip install semantica` (core)
|
||||
- `pip install semantica[openclaw]` (adds `requests` for the REST path)
|
||||
- OpenClaw ≥ latest — [openclaw.ai](https://openclaw.ai)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Semantica × OpenClaw Integration
|
||||
==================================
|
||||
|
||||
First-class integration between the Semantica semantic intelligence stack and
|
||||
`OpenClaw <https://openclaw.ai>`_ — the open-source personal AI agent platform.
|
||||
|
||||
OpenClaw connects to external tools via MCP (Model Context Protocol). This
|
||||
integration exposes the full Semantica MCP surface (12 tools, 3 resources) to
|
||||
any OpenClaw agent and also ships a lightweight ``OpenClawKGTool`` that can be
|
||||
dropped directly into an OpenClaw SOUL.md tool-list as a native tool.
|
||||
|
||||
Public surface
|
||||
--------------
|
||||
OpenClawKGTool — Thin wrapper around the Semantica REST API usable as an
|
||||
OpenClaw native tool (no MCP gateway required)
|
||||
OpenClawMCPConfig — Helper that emits the ``mcporter.json`` snippet needed to
|
||||
wire Semantica's MCP server into an OpenClaw gateway
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
pip install semantica
|
||||
|
||||
>>> from integrations.openclaw import OpenClawKGTool, OpenClawMCPConfig
|
||||
>>> print(OpenClawMCPConfig().to_json()) # paste into mcporter.json
|
||||
>>> tool = OpenClawKGTool(base_url="http://localhost:8000")
|
||||
>>> result = tool.extract("OpenClaw is an open-source AI agent framework.")
|
||||
|
||||
MCP quick start
|
||||
---------------
|
||||
Run the Semantica MCP server once::
|
||||
|
||||
python -m semantica.mcp_server
|
||||
|
||||
Then add the printed config snippet to your OpenClaw ``mcporter.json`` and
|
||||
restart the OpenClaw Gateway::
|
||||
|
||||
openclaw gateway restart
|
||||
|
||||
All 12 Semantica tools are then available as native OpenClaw agent tools.
|
||||
|
||||
Compatibility
|
||||
-------------
|
||||
Requires ``semantica >= 0.3.0``. The MCP path requires ``python >= 3.8`` and
|
||||
a running ``semantica.mcp_server`` instance. The REST path requires a running
|
||||
``semantica.server`` instance (``python -m semantica.server``, port 8000 by
|
||||
default).
|
||||
"""
|
||||
|
||||
from .mcp_tool import OpenClawKGTool, OpenClawMCPConfig
|
||||
|
||||
__all__ = [
|
||||
"OpenClawKGTool",
|
||||
"OpenClawMCPConfig",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
OpenClaw ↔ Semantica bridge
|
||||
============================
|
||||
|
||||
Two integration paths:
|
||||
|
||||
1. **MCP (recommended)** — ``OpenClawMCPConfig`` emits the ``mcporter.json``
|
||||
snippet that wires Semantica's MCP server into the OpenClaw Gateway.
|
||||
All 12 Semantica MCP tools become native OpenClaw agent tools with no
|
||||
extra code.
|
||||
|
||||
2. **REST** — ``OpenClawKGTool`` is a plain Python class that calls the
|
||||
Semantica REST API (port 8000) and can be registered as an OpenClaw
|
||||
native tool via SOUL.md ``tools:`` entries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP config helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OpenClawMCPConfig:
|
||||
"""
|
||||
Generates the ``mcporter.json`` entry needed to connect Semantica's MCP
|
||||
server to the OpenClaw Gateway.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
server_command:
|
||||
Shell command used to launch the Semantica MCP server.
|
||||
Defaults to ``"python -m semantica.mcp_server"``.
|
||||
transport:
|
||||
MCP transport protocol. OpenClaw supports ``"stdio"`` (default)
|
||||
and ``"sse"``.
|
||||
name:
|
||||
Key used in ``mcporter.json``. Defaults to ``"semantica"``.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> cfg = OpenClawMCPConfig()
|
||||
>>> print(cfg.to_json())
|
||||
# → paste into ~/.openclaw/mcporter.json, then:
|
||||
# → openclaw gateway restart
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_command: str = "python -m semantica.mcp_server",
|
||||
transport: str = "stdio",
|
||||
name: str = "semantica",
|
||||
) -> None:
|
||||
self.server_command = server_command
|
||||
self.transport = transport
|
||||
self.name = name
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the config as a plain dict."""
|
||||
parts = self.server_command.split()
|
||||
return {
|
||||
"mcpServers": {
|
||||
self.name: {
|
||||
"command": parts[0],
|
||||
"args": parts[1:],
|
||||
"transport": self.transport,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
"""Return the config as a JSON string."""
|
||||
return json.dumps(self.to_dict(), indent=indent)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"OpenClawMCPConfig(name={self.name!r}, transport={self.transport!r})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REST-based native tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OpenClawKGTool:
|
||||
"""
|
||||
A Semantica knowledge-graph tool callable from an OpenClaw agent.
|
||||
|
||||
Wraps the Semantica REST API so that an OpenClaw agent configured with
|
||||
this tool (via SOUL.md ``tools:`` entries or programmatic registration)
|
||||
can extract entities, record decisions, query the graph, and more —
|
||||
without requiring the MCP gateway.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
base_url:
|
||||
Base URL of the running Semantica REST server.
|
||||
Defaults to ``"http://localhost:8000"``.
|
||||
timeout:
|
||||
Request timeout in seconds. Defaults to ``30``.
|
||||
|
||||
Notes
|
||||
-----
|
||||
``requests`` is used for HTTP calls. It is listed as an optional
|
||||
dependency under ``semantica[openclaw]``; install it with::
|
||||
|
||||
pip install semantica[openclaw]
|
||||
"""
|
||||
|
||||
TOOL_NAME = "semantica_kg"
|
||||
TOOL_DESCRIPTION = (
|
||||
"Semantica knowledge-graph tool. "
|
||||
"Supports entity extraction, decision recording, graph querying, "
|
||||
"causal chain analysis, reasoning, and multi-format export."
|
||||
)
|
||||
|
||||
def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 30) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self._session: Any = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_session(self) -> Any:
|
||||
if self._session is None:
|
||||
try:
|
||||
import requests
|
||||
self._session = requests.Session()
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"The 'requests' package is required for OpenClawKGTool. "
|
||||
"Install it with: pip install semantica[openclaw]"
|
||||
) from exc
|
||||
return self._session
|
||||
|
||||
def _post(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
session = self._get_session()
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
response = session.post(url, json=payload, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
session = self._get_session()
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
response = session.get(url, params=params or {}, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Extraction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def extract(self, text: str) -> Dict[str, Any]:
|
||||
"""Extract entities and relations from *text*."""
|
||||
return self._post("/extract", {"text": text})
|
||||
|
||||
def extract_entities(self, text: str) -> List[Dict[str, Any]]:
|
||||
"""Return only the entity list from *text*."""
|
||||
result = self.extract(text)
|
||||
return result.get("entities", [])
|
||||
|
||||
def extract_relations(self, text: str) -> List[Dict[str, Any]]:
|
||||
"""Return only the relation list from *text*."""
|
||||
result = self.extract(text)
|
||||
return result.get("relations", [])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph mutation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_entity(self, label: str, entity_type: str = "Entity", **properties: Any) -> Dict[str, Any]:
|
||||
"""Add a node to the knowledge graph."""
|
||||
return self._post("/entities", {"label": label, "type": entity_type, **properties})
|
||||
|
||||
def add_relationship(
|
||||
self,
|
||||
source: str,
|
||||
target: str,
|
||||
relation_type: str,
|
||||
**properties: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add an edge between *source* and *target*."""
|
||||
return self._post(
|
||||
"/relationships",
|
||||
{"source": source, "target": target, "type": relation_type, **properties},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Decisions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_decision(
|
||||
self,
|
||||
decision_text: str,
|
||||
context: Optional[str] = None,
|
||||
**metadata: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Record a decision in the graph."""
|
||||
payload: Dict[str, Any] = {"decision": decision_text}
|
||||
if context:
|
||||
payload["context"] = context
|
||||
payload.update(metadata)
|
||||
return self._post("/decisions", payload)
|
||||
|
||||
def query_decisions(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search recorded decisions."""
|
||||
result = self._get("/decisions/search", {"q": query, "limit": limit})
|
||||
return result.get("decisions", [])
|
||||
|
||||
def find_precedents(self, decision_text: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Find past decisions similar to *decision_text*."""
|
||||
result = self._post("/decisions/precedents", {"decision": decision_text, "top_k": top_k})
|
||||
return result.get("precedents", [])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Analytics & reasoning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_causal_chain(self, node_id: str, depth: int = 3) -> Dict[str, Any]:
|
||||
"""Retrieve the causal chain rooted at *node_id*."""
|
||||
return self._get("/causal-chain", {"node_id": node_id, "depth": depth})
|
||||
|
||||
def run_reasoning(self, rules: List[str], facts: List[str]) -> Dict[str, Any]:
|
||||
"""Run the Semantica forward-chaining reasoner."""
|
||||
return self._post("/reason", {"rules": rules, "facts": facts})
|
||||
|
||||
def get_graph_analytics(self) -> Dict[str, Any]:
|
||||
"""Return graph-level analytics (centrality, communities, etc.)."""
|
||||
return self._get("/analytics")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Export
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def export_graph(self, fmt: str = "json") -> str:
|
||||
"""Export the graph in *fmt* (``json``, ``ttl``, ``graphml``, …)."""
|
||||
result = self._get("/export", {"format": fmt})
|
||||
return result.get("data", "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Summary
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_graph_summary(self) -> Dict[str, Any]:
|
||||
"""Return a high-level summary of the current graph."""
|
||||
return self._get("/graph/summary")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"OpenClawKGTool(base_url={self.base_url!r})"
|
||||
@@ -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
|
||||
```
|
||||