mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-30 04:40:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa74c6a8c |
@@ -7,103 +7,6 @@ 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.
|
||||
|
||||
Generated
-45
@@ -19,7 +19,6 @@
|
||||
"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",
|
||||
@@ -3468,50 +3467,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"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",
|
||||
|
||||
+22
-958
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
/* ── Semantica Explorer — Global CSS Reset ── */
|
||||
|
||||
@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');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
@@ -12,7 +12,7 @@ html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'IBM Plex Sans', 'Space Grotesk', sans-serif;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #0d1117;
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
GraphArrowVisibilityPolicy,
|
||||
GraphBadgeKind,
|
||||
GraphEdgeVariant,
|
||||
GraphEntityShapeVariant,
|
||||
GraphLabelVisibilityPolicy,
|
||||
GraphNodeShapeVariant,
|
||||
} from "../workspaces/GraphWorkspace/graphTheme";
|
||||
@@ -37,7 +36,6 @@ export interface NodeAttributes {
|
||||
borderSize?: number;
|
||||
nodeVariant?: GraphNodeShapeVariant;
|
||||
nodeShapeVariant?: GraphNodeShapeVariant;
|
||||
entityShape?: GraphEntityShapeVariant;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeCount?: number;
|
||||
ringColor?: string;
|
||||
|
||||
@@ -25,12 +25,8 @@ import {
|
||||
collectInteractionRefreshTargets,
|
||||
createInteractionState,
|
||||
isEdgeInteractable,
|
||||
classifyFullGraphEdge,
|
||||
mapFullEdgeClassToVisualState,
|
||||
resolveEdgeElementStyle,
|
||||
resolveEdgeVisualState,
|
||||
resolveDistanceEdgeStyle,
|
||||
resolveDistanceNodeStyle,
|
||||
resolveNodeElementStyle,
|
||||
resolveNodeVisualState,
|
||||
} from "./graphSceneState";
|
||||
@@ -51,30 +47,15 @@ import {
|
||||
drawSemanticaNodeHover,
|
||||
drawSemanticaNodeLabel,
|
||||
} from "./sigmaNativeRendering";
|
||||
import {
|
||||
buildGraphStructureCurveCache,
|
||||
clearGraphStructureLayer,
|
||||
createGraphStructureCacheKey,
|
||||
drawGraphStructureLayer,
|
||||
evaluateGraphStructureLayerGate,
|
||||
getGraphStructureLayerDiagnostics,
|
||||
type GraphStructureCurveCache,
|
||||
} from "./graphStructureLayer";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDistanceVisualState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphFullEdgeClass,
|
||||
GraphFullEdgeClassCounts,
|
||||
GraphFullEdgeClassDiagnostics,
|
||||
GraphInteractionState,
|
||||
GraphLayoutStatus,
|
||||
GraphRuntimeDiagnosticsSnapshot,
|
||||
GraphStructureLayerDiagnostics,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
@@ -104,7 +85,6 @@ export interface GraphCanvasProps {
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
effectsState: GraphEffectsState;
|
||||
temporalState?: GraphTemporalState | null;
|
||||
isLayoutRunning: boolean;
|
||||
@@ -118,7 +98,7 @@ export interface GraphCanvasProps {
|
||||
onSceneRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
|
||||
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
|
||||
onCameraStateChange?: (cameraState: GraphCameraState) => void;
|
||||
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
|
||||
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
|
||||
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
|
||||
}
|
||||
|
||||
@@ -820,7 +800,6 @@ function drawNodeBadge(
|
||||
}
|
||||
|
||||
type ReducerSceneState = {
|
||||
viewMode: GraphViewMode;
|
||||
zoomTier: GraphZoomTier;
|
||||
hoveredNodeId: string | null;
|
||||
selectedNodeId: string;
|
||||
@@ -831,135 +810,15 @@ type ReducerSceneState = {
|
||||
edgeEndpointIds: Set<string>;
|
||||
pathNodeIds: Set<string>;
|
||||
pathEdgeIds: Set<string>;
|
||||
highlightedIncidentEdgeIds: Set<string>;
|
||||
overviewBackboneEdgeIds: Set<string>;
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
};
|
||||
|
||||
const FULL_EDGE_CLASSES: GraphFullEdgeClass[] = [
|
||||
"hidden",
|
||||
"backbone",
|
||||
"bridge",
|
||||
"local-context",
|
||||
"selected",
|
||||
"path",
|
||||
"muted",
|
||||
];
|
||||
|
||||
function createFullEdgeClassCounts(): GraphFullEdgeClassCounts {
|
||||
return FULL_EDGE_CLASSES.reduce((counts, edgeClass) => {
|
||||
counts[edgeClass] = 0;
|
||||
return counts;
|
||||
}, {} as GraphFullEdgeClassCounts);
|
||||
}
|
||||
|
||||
function getIncidentEdgeRevealCap(viewMode: GraphViewMode, zoomTier: GraphZoomTier): number {
|
||||
return GRAPH_THEME.edges.contextCaps[viewMode]?.[zoomTier] ?? 0;
|
||||
}
|
||||
|
||||
function scoreIncidentEdge(
|
||||
attrs: EdgeAttributes,
|
||||
edgeId: string,
|
||||
otherEndpointId: string,
|
||||
visibleNeighborIds: Set<string>,
|
||||
focusIds: Set<string>,
|
||||
pathEdgeIds: Set<string>,
|
||||
selectedEdgeId: string,
|
||||
): number {
|
||||
if (pathEdgeIds.has(edgeId)) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
if (selectedEdgeId && edgeId === selectedEdgeId) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
const weight = Math.max(Number(attrs.weight ?? attrs.representativeWeight ?? 1) || 1, 1);
|
||||
const normalizedWeight = Math.min(1, Math.log1p(weight) / Math.log(25));
|
||||
const visualPriority = Math.max(0, Math.min(Number(attrs.visualPriority ?? 0), 1));
|
||||
const relationshipStrength = Math.max(0, Math.min(Number(attrs.relationshipStrength ?? 0), 1));
|
||||
|
||||
return (visibleNeighborIds.has(otherEndpointId) ? 6 : 0)
|
||||
+ (focusIds.has(otherEndpointId) ? 1.25 : 0)
|
||||
+ visualPriority * 2
|
||||
+ normalizedWeight * 1.4
|
||||
+ relationshipStrength;
|
||||
}
|
||||
|
||||
function buildHighlightedIncidentEdgeIds(
|
||||
displayGraph: GraphSceneGraph,
|
||||
interactionState: GraphInteractionState,
|
||||
displayState: GraphDisplayStateSnapshot | undefined,
|
||||
focusIds: Set<string>,
|
||||
pathEdgeIds: Set<string>,
|
||||
): Set<string> {
|
||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||
if (!primaryNodeId || !displayGraph.hasNode(primaryNodeId)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const cap = getIncidentEdgeRevealCap(interactionState.viewMode, interactionState.zoomTier);
|
||||
if (cap <= 0 && !interactionState.selectedEdgeId && pathEdgeIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const visibleNeighborIds = new Set(
|
||||
(displayState?.selectedVisibleNeighborIds ?? [])
|
||||
.filter((nodeId) => displayGraph.hasNode(nodeId)),
|
||||
);
|
||||
const candidates: Array<{ edgeId: string; score: number }> = [];
|
||||
|
||||
displayGraph.edges(primaryNodeId).forEach((edge) => {
|
||||
const edgeId = String(edge);
|
||||
if (!displayGraph.hasEdge(edgeId)) {
|
||||
return;
|
||||
}
|
||||
const [source, target] = displayGraph.extremities(edgeId);
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
const otherEndpointId = sourceId === primaryNodeId ? targetId : sourceId;
|
||||
const attrs = displayGraph.getEdgeAttributes(edgeId) as EdgeAttributes;
|
||||
candidates.push({
|
||||
edgeId,
|
||||
score: scoreIncidentEdge(
|
||||
attrs,
|
||||
edgeId,
|
||||
otherEndpointId,
|
||||
visibleNeighborIds,
|
||||
focusIds,
|
||||
pathEdgeIds,
|
||||
interactionState.selectedEdgeId,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
|
||||
const selected = new Set<string>();
|
||||
candidates.forEach((candidate) => {
|
||||
if (
|
||||
candidate.edgeId === interactionState.selectedEdgeId
|
||||
|| pathEdgeIds.has(candidate.edgeId)
|
||||
|| selected.size < cap
|
||||
) {
|
||||
selected.add(candidate.edgeId);
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
function buildReducerSceneState(
|
||||
displayGraph: GraphSceneGraph,
|
||||
interactionState: GraphInteractionState,
|
||||
displayState?: GraphDisplayStateSnapshot,
|
||||
analyticsSnapshot?: GraphAnalyticsSnapshot | null,
|
||||
distanceVisualState?: GraphDistanceVisualState,
|
||||
analyticsSnapshot: GraphAnalyticsSnapshot | null,
|
||||
): ReducerSceneState {
|
||||
const { viewMode, zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
|
||||
const { zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
|
||||
const primaryNodeId = hoveredNodeId || selectedNodeId;
|
||||
const focusIds = primaryNodeId
|
||||
? (
|
||||
@@ -968,10 +827,8 @@ function buildReducerSceneState(
|
||||
: new Set<string>()
|
||||
)
|
||||
: new Set<string>();
|
||||
const pathEdgeIds = buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds);
|
||||
|
||||
return {
|
||||
viewMode,
|
||||
zoomTier,
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
@@ -981,75 +838,8 @@ function buildReducerSceneState(
|
||||
focusIds,
|
||||
edgeEndpointIds: buildEdgeEndpointSet(displayGraph, selectedEdgeId),
|
||||
pathNodeIds: new Set(activePath),
|
||||
pathEdgeIds,
|
||||
pathEdgeIds: buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds),
|
||||
overviewBackboneEdgeIds: new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []),
|
||||
distanceVisualState,
|
||||
highlightedIncidentEdgeIds: buildHighlightedIncidentEdgeIds(
|
||||
displayGraph,
|
||||
interactionState,
|
||||
displayState,
|
||||
focusIds,
|
||||
pathEdgeIds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getFullGraphEdgeClass(
|
||||
displayGraph: GraphSceneGraph,
|
||||
edgeId: string,
|
||||
currentState: ReducerSceneState,
|
||||
): GraphFullEdgeClass {
|
||||
if (!displayGraph.hasEdge(edgeId)) {
|
||||
return "hidden";
|
||||
}
|
||||
|
||||
const [source, target] = displayGraph.extremities(edgeId);
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
const sourceAttrs = displayGraph.hasNode(sourceId)
|
||||
? displayGraph.getNodeAttributes(sourceId) as NodeAttributes
|
||||
: undefined;
|
||||
const targetAttrs = displayGraph.hasNode(targetId)
|
||||
? displayGraph.getNodeAttributes(targetId) as NodeAttributes
|
||||
: undefined;
|
||||
|
||||
return classifyFullGraphEdge(
|
||||
edgeId,
|
||||
sourceId,
|
||||
targetId,
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.highlightedIncidentEdgeIds,
|
||||
currentState.overviewBackboneEdgeIds,
|
||||
sourceAttrs,
|
||||
targetAttrs,
|
||||
);
|
||||
}
|
||||
|
||||
function buildFullGraphEdgeClassDiagnostics(
|
||||
displayGraph: GraphSceneGraph,
|
||||
currentState: ReducerSceneState,
|
||||
): GraphFullEdgeClassDiagnostics {
|
||||
const counts = createFullEdgeClassCounts();
|
||||
|
||||
displayGraph.forEachEdge((edgeId) => {
|
||||
const edgeClass = currentState.viewMode === "full"
|
||||
? getFullGraphEdgeClass(displayGraph, String(edgeId), currentState)
|
||||
: "hidden";
|
||||
counts[edgeClass] += 1;
|
||||
});
|
||||
|
||||
return {
|
||||
mode: currentState.viewMode,
|
||||
zoomTier: currentState.zoomTier,
|
||||
totalEdges: displayGraph.size,
|
||||
visibleEdges: displayGraph.size - counts.hidden,
|
||||
counts,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1099,34 +889,21 @@ function applySceneState(
|
||||
data.label,
|
||||
cameraRatio,
|
||||
);
|
||||
const distanceStyle = currentState.viewMode === "full"
|
||||
? resolveDistanceNodeStyle(
|
||||
GRAPH_THEME,
|
||||
currentState.zoomTier,
|
||||
style,
|
||||
currentState.distanceVisualState,
|
||||
String(node),
|
||||
)
|
||||
: {};
|
||||
const resolvedStyle = { ...style, ...distanceStyle };
|
||||
|
||||
return {
|
||||
...data,
|
||||
color: resolvedStyle.color,
|
||||
shellColor: resolvedStyle.shellColor,
|
||||
coreScale: resolvedStyle.coreScale,
|
||||
size: resolvedStyle.size,
|
||||
forceLabel: resolvedStyle.forceLabel,
|
||||
label: resolvedStyle.label,
|
||||
zIndex: resolvedStyle.zIndex,
|
||||
hidden: resolvedStyle.hidden,
|
||||
borderColor: resolvedStyle.borderColor,
|
||||
borderSize: resolvedStyle.borderSize,
|
||||
ringColor: resolvedStyle.showRing ? resolvedStyle.ringColor : resolvedStyle.borderColor,
|
||||
ringSize: resolvedStyle.ringSize,
|
||||
entityShape: resolvedStyle.entityShape,
|
||||
entityShapeKind: resolvedStyle.entityShapeKind,
|
||||
entityAspectRatio: resolvedStyle.entityAspectRatio,
|
||||
color: style.color,
|
||||
shellColor: style.shellColor,
|
||||
coreScale: style.coreScale,
|
||||
size: style.size,
|
||||
forceLabel: style.forceLabel,
|
||||
label: style.label,
|
||||
zIndex: style.zIndex,
|
||||
hidden: style.hidden,
|
||||
borderColor: style.borderColor,
|
||||
borderSize: style.borderSize,
|
||||
ringColor: style.showRing ? style.ringColor : style.borderColor,
|
||||
ringSize: style.ringSize,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1150,35 +927,18 @@ function applySceneState(
|
||||
const attrs = data as EdgeAttributes;
|
||||
const [source, target] = currentGraph.extremities(edge);
|
||||
const stableEdgeId = String(edge);
|
||||
const hasActiveInteraction = Boolean(
|
||||
currentState.hoveredNodeId
|
||||
|| currentState.selectedNodeId
|
||||
|| currentState.selectedEdgeId
|
||||
|| currentState.pathEdgeIds.size > 0,
|
||||
const state = resolveEdgeVisualState(
|
||||
stableEdgeId,
|
||||
source,
|
||||
target,
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.overviewBackboneEdgeIds,
|
||||
);
|
||||
const fullEdgeClass = currentState.viewMode === "full"
|
||||
? getFullGraphEdgeClass(currentGraph, stableEdgeId, currentState)
|
||||
: undefined;
|
||||
const state = currentState.viewMode === "full"
|
||||
? mapFullEdgeClassToVisualState(
|
||||
fullEdgeClass ?? "hidden",
|
||||
{
|
||||
hoveredNodeId: currentState.hoveredNodeId,
|
||||
hasActiveInteraction,
|
||||
},
|
||||
)
|
||||
: resolveEdgeVisualState(
|
||||
stableEdgeId,
|
||||
String(source),
|
||||
String(target),
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.highlightedIncidentEdgeIds,
|
||||
);
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
currentState.zoomTier,
|
||||
@@ -1186,29 +946,16 @@ function applySceneState(
|
||||
attrs,
|
||||
source,
|
||||
target,
|
||||
currentState.viewMode,
|
||||
stableEdgeId,
|
||||
fullEdgeClass,
|
||||
);
|
||||
const distanceStyle = currentState.viewMode === "full"
|
||||
? resolveDistanceEdgeStyle(
|
||||
style,
|
||||
currentState.distanceVisualState,
|
||||
String(source),
|
||||
String(target),
|
||||
fullEdgeClass,
|
||||
)
|
||||
: {};
|
||||
const resolvedStyle = { ...style, ...distanceStyle };
|
||||
|
||||
return {
|
||||
...data,
|
||||
hidden: resolvedStyle.hidden,
|
||||
type: resolvedStyle.type,
|
||||
color: resolvedStyle.color,
|
||||
size: resolvedStyle.size,
|
||||
zIndex: resolvedStyle.zIndex,
|
||||
curvature: resolvedStyle.curvature,
|
||||
hidden: style.hidden,
|
||||
type: style.type,
|
||||
color: style.color,
|
||||
size: style.size,
|
||||
zIndex: style.zIndex,
|
||||
curvature: style.curvature,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1252,7 +999,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
selectedEdgeId,
|
||||
activePath = [],
|
||||
activePathEdgeIds = [],
|
||||
distanceVisualState,
|
||||
effectsState,
|
||||
temporalState,
|
||||
isLayoutRunning,
|
||||
@@ -1271,10 +1017,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const overlayRef = useRef<HTMLCanvasElement>(null);
|
||||
const structureLayerCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const structureLayerCacheRef = useRef<GraphStructureCurveCache | null>(null);
|
||||
const structureLayerLastDrawAtRef = useRef<number | null>(null);
|
||||
const structureLayerDiagnosticsRef = useRef<GraphStructureLayerDiagnostics | null>(null);
|
||||
const sigmaRef = useRef<Sigma | null>(null);
|
||||
const fa2Ref = useRef<FA2Layout | null>(null);
|
||||
const behaviorContextRef = useRef<GraphBehaviorContext | null>(null);
|
||||
@@ -1287,7 +1029,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const graphVersionRef = useRef(graphVersion);
|
||||
const selectedNodeIdRef = useRef(selectedNodeId);
|
||||
const focusedNodeIdRef = useRef(focusedNodeId);
|
||||
const distanceVisualStateRef = useRef(distanceVisualState);
|
||||
const viewModeRef = useRef(viewMode);
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
const onEdgeClickRef = useRef(onEdgeClick);
|
||||
@@ -1296,7 +1037,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
|
||||
const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
|
||||
const [layoutSettledEpoch, setLayoutSettledEpoch] = useState(0);
|
||||
const appliedGraphVersionRef = useRef<number | null>(null);
|
||||
const fittedDisplaySignatureRef = useRef<DisplayFitSignature | null>(null);
|
||||
const layoutSyncFrameRef = useRef<number | null>(null);
|
||||
@@ -1314,7 +1054,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
graphVersionRef.current = graphVersion;
|
||||
selectedNodeIdRef.current = selectedNodeId;
|
||||
focusedNodeIdRef.current = focusedNodeId;
|
||||
distanceVisualStateRef.current = distanceVisualState;
|
||||
viewModeRef.current = viewMode;
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
onEdgeClickRef.current = onEdgeClick;
|
||||
@@ -1361,13 +1100,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
|
||||
interactionStateRef.current = interactionState;
|
||||
const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
|
||||
const previousDistanceVisualStateRef = useRef<GraphDistanceVisualState | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!isLayoutRunning) {
|
||||
setLayoutSettledEpoch((epoch) => epoch + 1);
|
||||
}
|
||||
}, [displayGraph, graphVersion, isLayoutRunning]);
|
||||
|
||||
const shouldComputeCommunities = effectsState.communitiesEnabled || effectsState.semanticRegionsEnabled;
|
||||
const shouldComputeCentrality = effectsState.centralityEnabled || effectsState.semanticRegionsEnabled || effectsState.contoursEnabled;
|
||||
const analyticsBase = useMemo(
|
||||
@@ -1378,55 +1110,11 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
|
||||
);
|
||||
const reducerSceneState = useMemo(
|
||||
() => buildReducerSceneState(displayGraph, interactionState, displayState, analyticsSnapshot, distanceVisualState),
|
||||
[analyticsSnapshot, displayGraph, displayState, distanceVisualState, interactionState],
|
||||
() => buildReducerSceneState(displayGraph, interactionState, analyticsSnapshot),
|
||||
[analyticsSnapshot, displayGraph, interactionState],
|
||||
);
|
||||
const reducerSceneStateRef = useRef<ReducerSceneState>(reducerSceneState);
|
||||
reducerSceneStateRef.current = reducerSceneState;
|
||||
const edgeClassDiagnostics = useMemo(
|
||||
() => buildFullGraphEdgeClassDiagnostics(displayGraph, reducerSceneState),
|
||||
[displayGraph, reducerSceneState],
|
||||
);
|
||||
const structureLayerGate = useMemo(
|
||||
() => evaluateGraphStructureLayerGate({
|
||||
mode: GRAPH_THEME.edges.fullGraphStructureLayer.mode,
|
||||
viewMode,
|
||||
isLayoutRunning,
|
||||
edgeDiagnostics: edgeClassDiagnostics,
|
||||
minimumLiteralEdges: GRAPH_THEME.edges.fullGraphStructureLayer.minimumLiteralEdges,
|
||||
}),
|
||||
[edgeClassDiagnostics, isLayoutRunning, viewMode],
|
||||
);
|
||||
const structureLayerCache = useMemo(() => {
|
||||
if (!structureLayerGate.enabled) {
|
||||
return null;
|
||||
}
|
||||
const cacheKey = createGraphStructureCacheKey({
|
||||
graphVersion,
|
||||
zoomTier,
|
||||
layoutSettledEpoch,
|
||||
overviewBackboneEdgeIds: reducerSceneState.overviewBackboneEdgeIds,
|
||||
});
|
||||
return buildGraphStructureCurveCache({
|
||||
graphRef: displayGraph,
|
||||
cacheKey,
|
||||
classifyEdge: (edgeId) => getFullGraphEdgeClass(displayGraph, edgeId, reducerSceneState),
|
||||
maxCurves: GRAPH_THEME.edges.fullGraphStructureLayer.maxCurves,
|
||||
curveStrength: GRAPH_THEME.edges.fullGraphStructureLayer.curveStrength,
|
||||
});
|
||||
}, [displayGraph, graphVersion, layoutSettledEpoch, reducerSceneState, structureLayerGate.enabled, zoomTier]);
|
||||
structureLayerCacheRef.current = structureLayerCache;
|
||||
const structureLayerDiagnostics = useMemo(
|
||||
() => getGraphStructureLayerDiagnostics({
|
||||
gate: structureLayerGate,
|
||||
cache: structureLayerCache,
|
||||
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
|
||||
canvasAvailable: Boolean(structureLayerCanvasRef.current),
|
||||
lastDrawAt: structureLayerLastDrawAtRef.current,
|
||||
}),
|
||||
[structureLayerCache, structureLayerGate],
|
||||
);
|
||||
structureLayerDiagnosticsRef.current = structureLayerDiagnostics;
|
||||
const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
|
||||
graphVersion,
|
||||
viewMode,
|
||||
@@ -1549,40 +1237,17 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
graphVersion: graphVersionRef.current,
|
||||
viewMode: viewModeRef.current,
|
||||
});
|
||||
fitDisplayGraphInView();
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDisplayNodeId = selectionNodeIds[0];
|
||||
const selectedDisplayData = sigma.getNodeDisplayData(selectedDisplayNodeId);
|
||||
if (selectedDisplayData) {
|
||||
const viewportPoint = sigma.graphToViewport({
|
||||
x: selectedDisplayData.x,
|
||||
y: selectedDisplayData.y,
|
||||
});
|
||||
const dimensions = sigma.getDimensions();
|
||||
if (isPointNearViewport(viewportPoint, dimensions.width, dimensions.height, 96)) {
|
||||
debugGraphRuntime("camera-selection-visible-noop", {
|
||||
nodeId,
|
||||
selectedDisplayNodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
viewMode: viewModeRef.current,
|
||||
viewportX: viewportPoint.x,
|
||||
viewportY: viewportPoint.y,
|
||||
});
|
||||
sigma.scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = computeDisplayedNodeBounds(sigma, selectionNodeIds);
|
||||
const bounds = computeGraphSpaceBounds(currentDisplayGraph, selectionNodeIds);
|
||||
if (!bounds) {
|
||||
if (attempt < 3) {
|
||||
debugGraphRuntime("camera-selection-display-bounds-deferred", {
|
||||
debugGraphRuntime("camera-selection-deferred", {
|
||||
nodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
attempt: attempt + 1,
|
||||
viewMode: viewModeRef.current,
|
||||
contextCount: selectionNodeIds.length,
|
||||
});
|
||||
if (deferredFocusFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(deferredFocusFrameRef.current);
|
||||
@@ -1592,43 +1257,45 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
centerSelectionInViewInternal(nodeId, attempt + 1);
|
||||
});
|
||||
} else {
|
||||
debugGraphRuntime("camera-selection-display-bounds-fallback-fit", {
|
||||
debugGraphRuntime("camera-selection-fallback-fit", {
|
||||
nodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
viewMode: viewModeRef.current,
|
||||
contextCount: selectionNodeIds.length,
|
||||
});
|
||||
fitDisplayGraphInView();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = sigma.getCamera();
|
||||
const currentCameraState = camera.getState();
|
||||
const target = {
|
||||
x: (bounds.minX + bounds.maxX) / 2,
|
||||
y: (bounds.minY + bounds.maxY) / 2,
|
||||
ratio: currentCameraState.ratio,
|
||||
angle: currentCameraState.angle,
|
||||
};
|
||||
const globalBounds = computeDisplayedGraphBounds(sigma, currentDisplayGraph);
|
||||
const selectionBBox = expandGraphSpaceBounds(bounds, globalBounds, {
|
||||
paddingRatio: 0.2,
|
||||
minSpanRatio: 0.035,
|
||||
minSpanFloor: 0.02,
|
||||
});
|
||||
if (!selectionBBox) {
|
||||
debugGraphRuntime("camera-selection-invalid-bounds", {
|
||||
nodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
count: bounds.count,
|
||||
});
|
||||
fitDisplayGraphInView();
|
||||
return;
|
||||
}
|
||||
|
||||
debugGraphRuntime("camera-selection-gentle-center", {
|
||||
animateCameraToBounds("fit-selection-context", selectionBBox, {
|
||||
nodeId,
|
||||
contextCount: bounds.count,
|
||||
boundsSource: "displayed-selection-context",
|
||||
minX: bounds.minX,
|
||||
maxX: bounds.maxX,
|
||||
minY: bounds.minY,
|
||||
maxY: bounds.maxY,
|
||||
targetX: target.x,
|
||||
targetY: target.y,
|
||||
preservedRatio: target.ratio,
|
||||
referenceMinX: globalBounds?.minX ?? null,
|
||||
referenceMaxX: globalBounds?.maxX ?? null,
|
||||
referenceMinY: globalBounds?.minY ?? null,
|
||||
referenceMaxY: globalBounds?.maxY ?? null,
|
||||
});
|
||||
|
||||
void camera.animate(
|
||||
target,
|
||||
{ duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
|
||||
);
|
||||
}, []);
|
||||
}, [animateCameraToBounds, fitDisplayGraphInView]);
|
||||
|
||||
const centerGroupedSelectionInView = useCallback((nodeId: string) => {
|
||||
const sigma = sigmaRef.current;
|
||||
@@ -1913,22 +1580,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const structureCanvas = sigma.createCanvas("structure", {
|
||||
beforeLayer: "nodes",
|
||||
afterLayer: "edges",
|
||||
style: {
|
||||
pointerEvents: "none",
|
||||
},
|
||||
});
|
||||
structureLayerCanvasRef.current = structureCanvas;
|
||||
} catch (error) {
|
||||
debugGraphRuntime("structure-layer-create-failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
structureLayerCanvasRef.current = null;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
syncCameraState(sigma);
|
||||
});
|
||||
@@ -1956,14 +1607,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
debugGraphRuntime("sigma-killed", {
|
||||
graphVersion: graphVersionRef.current,
|
||||
});
|
||||
if (structureLayerCanvasRef.current) {
|
||||
try {
|
||||
sigma.killLayer("structure");
|
||||
} catch {
|
||||
// Sigma.kill() also cleans layers; ignore if already removed.
|
||||
}
|
||||
}
|
||||
structureLayerCanvasRef.current = null;
|
||||
sigma.kill();
|
||||
}
|
||||
if (deferredFocusFrameRef.current !== null) {
|
||||
@@ -2006,7 +1649,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
appliedGraphVersionRef.current = graphVersion;
|
||||
fittedDisplaySignatureRef.current = null;
|
||||
previousInteractionStateRef.current = null;
|
||||
previousDistanceVisualStateRef.current = undefined;
|
||||
behaviorContextRef.current = getBehaviorContext(sigma);
|
||||
if (runtimeRef.current) {
|
||||
runtimeRef.current.displayGraph = displayGraph;
|
||||
@@ -2122,30 +1764,11 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
container?.clientWidth ?? 0,
|
||||
container?.clientHeight ?? 0,
|
||||
);
|
||||
onDiagnosticsChange({
|
||||
effectAvailability: availability,
|
||||
edgeClasses: edgeClassDiagnostics,
|
||||
structureLayer: structureLayerDiagnosticsRef.current ?? structureLayerDiagnostics,
|
||||
distanceVisual: distanceVisualStateRef.current,
|
||||
});
|
||||
if (import.meta.env.DEV && effectsState.diagnosticsEnabled) {
|
||||
console.debug("[Edge Truth]", edgeClassDiagnostics);
|
||||
}
|
||||
}, [
|
||||
analyticsSnapshot,
|
||||
edgeClassDiagnostics,
|
||||
effectsState,
|
||||
interactionState,
|
||||
isLayoutRunning,
|
||||
onDiagnosticsChange,
|
||||
structureLayerDiagnostics,
|
||||
temporalState,
|
||||
distanceVisualState,
|
||||
]);
|
||||
onDiagnosticsChange(availability);
|
||||
}, [analyticsSnapshot, effectsState, interactionState, isLayoutRunning, onDiagnosticsChange, temporalState]);
|
||||
|
||||
useEffect(() => {
|
||||
previousInteractionStateRef.current = null;
|
||||
previousDistanceVisualStateRef.current = undefined;
|
||||
}, [displayGraph]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2155,68 +1778,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
}
|
||||
|
||||
const previousInteractionState = previousInteractionStateRef.current;
|
||||
const distanceVisualStateChanged = previousDistanceVisualStateRef.current !== distanceVisualState;
|
||||
const refreshTargets = !distanceVisualStateChanged && previousInteractionState
|
||||
const refreshTargets = previousInteractionState
|
||||
? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
|
||||
: undefined;
|
||||
|
||||
applySceneState(sigma, reducerSceneStateRef, reducerWarningStateRef, refreshTargets);
|
||||
previousInteractionStateRef.current = interactionState;
|
||||
previousDistanceVisualStateRef.current = distanceVisualState;
|
||||
}, [displayGraph, distanceVisualState, interactionState, reducerSceneStateRef]);
|
||||
|
||||
const drawStructureLayerFrame = useCallback(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
const canvas = structureLayerCanvasRef.current;
|
||||
const cache = structureLayerCacheRef.current;
|
||||
const diagnostics = getGraphStructureLayerDiagnostics({
|
||||
gate: structureLayerGate,
|
||||
cache,
|
||||
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
|
||||
canvasAvailable: Boolean(canvas),
|
||||
lastDrawAt: structureLayerLastDrawAtRef.current,
|
||||
});
|
||||
structureLayerDiagnosticsRef.current = diagnostics;
|
||||
|
||||
if (!sigma || !canvas || !diagnostics.enabled || !cache) {
|
||||
clearGraphStructureLayer(canvas);
|
||||
return;
|
||||
}
|
||||
|
||||
const drawn = drawGraphStructureLayer({
|
||||
sigma,
|
||||
canvas,
|
||||
cache,
|
||||
});
|
||||
if (drawn) {
|
||||
structureLayerLastDrawAtRef.current = Date.now();
|
||||
structureLayerDiagnosticsRef.current = {
|
||||
...diagnostics,
|
||||
lastDrawAt: structureLayerLastDrawAtRef.current,
|
||||
};
|
||||
}
|
||||
}, [structureLayerGate]);
|
||||
|
||||
useEffect(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
if (!graphReady || !sigma) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draw = () => drawStructureLayerFrame();
|
||||
sigma.on("afterRender", draw);
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
sigma.off("afterRender", draw);
|
||||
};
|
||||
}, [drawStructureLayerFrame, graphReady, structureLayerCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLayoutRunning || viewMode !== "full") {
|
||||
clearGraphStructureLayer(structureLayerCanvasRef.current);
|
||||
}
|
||||
}, [isLayoutRunning, viewMode]);
|
||||
}, [displayGraph, interactionState, reducerSceneStateRef]);
|
||||
|
||||
const drawOverlayFrame = useCallback(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
@@ -2279,19 +1847,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
...pathNodeIds,
|
||||
...(primaryNodeId ? [primaryNodeId] : []),
|
||||
]);
|
||||
const lensFocusIds = new Set<string>();
|
||||
if (primaryNodeId) {
|
||||
reducerSceneStateRef.current.highlightedIncidentEdgeIds.forEach((edgeId) => {
|
||||
if (!displayGraph.hasEdge(edgeId)) {
|
||||
return;
|
||||
}
|
||||
const [source, target] = displayGraph.extremities(edgeId);
|
||||
const otherEndpointId = String(source) === primaryNodeId ? String(target) : String(source);
|
||||
if (displayGraph.hasNode(otherEndpointId)) {
|
||||
lensFocusIds.add(otherEndpointId);
|
||||
}
|
||||
});
|
||||
}
|
||||
const now = performance.now() / 1000;
|
||||
|
||||
if (!isLayoutRunning) {
|
||||
@@ -2354,7 +1909,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
});
|
||||
|
||||
if (!isLayoutRunning && primaryNodeId && effectAvailability.lens.available) {
|
||||
drawLensLayer(context, sigma, primaryNodeId, lensFocusIds);
|
||||
drawLensLayer(context, sigma, primaryNodeId, focusIds);
|
||||
}
|
||||
|
||||
drawPathEffectsLayer(context, pathSegments, effectsState, effectAvailability, now);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import type { GraphSelectedNodeKind } from "./types";
|
||||
|
||||
export type LinkPrediction = {
|
||||
@@ -17,13 +17,6 @@ export type PathResponse = {
|
||||
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 {
|
||||
@@ -53,129 +46,6 @@ function sourceAttribution(properties: Record<string, unknown>) {
|
||||
.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 {
|
||||
@@ -213,13 +83,11 @@ function PathFlowViz({
|
||||
path,
|
||||
edgeIds,
|
||||
totalWeight,
|
||||
bottleneckNodeId,
|
||||
onFocusNode,
|
||||
}: {
|
||||
path: string[];
|
||||
edgeIds?: string[];
|
||||
totalWeight: number;
|
||||
bottleneckNodeId?: string | null;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}) {
|
||||
if (path.length === 0) {
|
||||
@@ -242,13 +110,10 @@ function PathFlowViz({
|
||||
{/* Node chip */}
|
||||
<button
|
||||
onClick={() => onFocusNode?.(nodeId)}
|
||||
title={nodeId === bottleneckNodeId ? `Bottleneck: ${nodeId}` : `Focus: ${nodeId}`}
|
||||
title={`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>
|
||||
@@ -307,10 +172,10 @@ export function GraphInspectorPanel({
|
||||
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 style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(74,163,255,0.08)", border: "1px solid rgba(74,163,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", background: "rgba(127,208,255,0.3)" }} />
|
||||
</div>
|
||||
<p style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 14, margin: 0, lineHeight: 1.6 }}>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0, lineHeight: 1.6 }}>
|
||||
Search for a node or click one in the canvas to inspect its properties.
|
||||
</p>
|
||||
</div>
|
||||
@@ -326,19 +191,19 @@ export function GraphInspectorPanel({
|
||||
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={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", 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>
|
||||
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
<h3 style={{ margin: 0, color: "#fff", 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 style={{ color: "#8b949e", 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 }}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? "Activate Focused mode to resolve this grouped selection to its canonical node."
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
@@ -368,23 +233,23 @@ export function GraphInspectorPanel({
|
||||
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={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
|
||||
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
<h3 style={{ margin: 0, color: "#fff", 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" }}>
|
||||
<div style={{ color: "#8b949e", 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 }}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? `Canonical node available: ${effectiveNodeId}`
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
@@ -402,7 +267,7 @@ export function GraphInspectorPanel({
|
||||
|
||||
{/* 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" }}>
|
||||
<div style={{ padding: "10px 12px", background: "rgba(88,166,255,0.08)", border: "1px solid rgba(88,166,255,0.2)", borderRadius: 8, fontSize: 12, color: "#79c0ff", fontFamily: "monospace" }}>
|
||||
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
|
||||
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
|
||||
</div>
|
||||
@@ -451,16 +316,12 @@ export function GraphInspectorPanel({
|
||||
<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} />
|
||||
</>
|
||||
<PathFlowViz
|
||||
path={pathResult.path}
|
||||
edgeIds={pathResult.edge_ids}
|
||||
totalWeight={pathResult.total_weight}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>
|
||||
Choose a target or click a candidate prediction to prepare a path trace.
|
||||
@@ -482,8 +343,8 @@ export function GraphInspectorPanel({
|
||||
>
|
||||
<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 style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{
|
||||
@@ -491,9 +352,9 @@ export function GraphInspectorPanel({
|
||||
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,
|
||||
background: "rgba(88,166,255,0.12)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#58a6ff",
|
||||
}}>
|
||||
{(prediction.score * 100).toFixed(1)}%
|
||||
</div>
|
||||
@@ -521,8 +382,8 @@ export function GraphInspectorPanel({
|
||||
<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" }}>
|
||||
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -542,8 +403,8 @@ export function GraphInspectorPanel({
|
||||
<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" }}>
|
||||
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -562,27 +423,27 @@ export function GraphInspectorPanel({
|
||||
|
||||
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,
|
||||
background: "rgba(4, 10, 18, 0.5)",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
color: "#edf5ff",
|
||||
borderRadius: 12,
|
||||
padding: "11px 13px",
|
||||
fontSize: 13,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.035)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||
};
|
||||
|
||||
const groupedSelectionNoticeStyle: CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "10px 12px",
|
||||
background: "rgba(98, 226, 205, 0.07)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
background: "rgba(88,166,255,0.08)",
|
||||
border: "1px solid rgba(88,166,255,0.2)",
|
||||
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}`,
|
||||
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||
color: "#fff",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
borderRadius: 12,
|
||||
padding: "9px 12px",
|
||||
cursor: "pointer",
|
||||
@@ -591,47 +452,47 @@ const actionButtonStyle: CSSProperties = {
|
||||
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)",
|
||||
boxShadow: `0 8px 22px ${GRAPH_THEME.palette.background.shellGlow}`,
|
||||
};
|
||||
|
||||
const secondaryActionButtonStyle: CSSProperties = {
|
||||
...actionButtonStyle,
|
||||
background: GRAPH_THEME.ui.control.defaultBg,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.defaultBorder}`,
|
||||
color: GRAPH_THEME.ui.control.defaultText,
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
color: "#c6d4e3",
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const predictionCardStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
background: "rgba(255, 255, 255, 0.035)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.12)",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const propertyCardStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.028)",
|
||||
background: "rgba(0, 0, 0, 0.2)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const subtleChipStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
color: GRAPH_THEME.ui.text.body,
|
||||
color: "#9fb6d2",
|
||||
padding: "4px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
@@ -639,13 +500,13 @@ const sectionStyle: CSSProperties = {
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
background: GRAPH_THEME.ui.surface.cardSubtle,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
background: "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
borderRadius: 14,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
@@ -666,9 +527,9 @@ const pathNodeChipStyle: CSSProperties = {
|
||||
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,
|
||||
background: "rgba(88,166,255,0.1)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#e6edf3",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
maxWidth: 160,
|
||||
@@ -681,8 +542,8 @@ const pathNodeIndexStyle: CSSProperties = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
background: "rgba(88,166,255,0.22)",
|
||||
color: "#79c0ff",
|
||||
fontSize: 9,
|
||||
fontWeight: 800,
|
||||
flexShrink: 0,
|
||||
@@ -698,7 +559,7 @@ const pathEdgeConnectorStyle: CSSProperties = {
|
||||
const pathEdgeLabelStyle: CSSProperties = {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: GRAPH_THEME.ui.text.subtle,
|
||||
color: "#6a7f97",
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
maxWidth: 70,
|
||||
@@ -706,41 +567,3 @@ const pathEdgeLabelStyle: CSSProperties = {
|
||||
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",
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -510,13 +510,8 @@ 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/path?${pathParams.toString()}`,
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
@@ -20,35 +19,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: ${GRAPH_THEME.ui.timeline.border} !important; }
|
||||
.sem-timeline-wrap .vis-panel { border-color: rgba(88, 166, 255, 0.15) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text {
|
||||
color: ${GRAPH_THEME.ui.timeline.text} !important;
|
||||
color: #8b949e !important;
|
||||
font-size: 11px !important;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text.vis-major {
|
||||
color: ${GRAPH_THEME.ui.timeline.textStrong} !important;
|
||||
color: #c9d1d9 !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 12px !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-time-axis .vis-grid.vis-minor { border-color: rgba(88, 166, 255, 0.07) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: rgba(88, 166, 255, 0.18) !important; }
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} {
|
||||
background: ${GRAPH_THEME.ui.timeline.playheadSoft} !important;
|
||||
background: rgba(88, 166, 255, 0.15) !important;
|
||||
width: 2px !important;
|
||||
cursor: ew-resize !important;
|
||||
z-index: 5 !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} > .vis-custom-time-marker {
|
||||
background: ${GRAPH_THEME.ui.timeline.playhead} !important;
|
||||
color: ${GRAPH_THEME.ui.text.inverse} !important;
|
||||
background: #58a6ff !important;
|
||||
color: #0d1117 !important;
|
||||
font-size: 10px !important;
|
||||
font-weight: 700 !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 1px 5px !important;
|
||||
white-space: nowrap !important;
|
||||
box-shadow: 0 0 8px rgba(98, 226, 205, 0.45) !important;
|
||||
box-shadow: 0 0 8px rgba(88, 166, 255, 0.7) !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-current-time { display: none !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
|
||||
@@ -167,14 +166,14 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
useEffect(() => () => stopPlay(), [stopPlay]);
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: "1px solid rgba(88, 166, 255, 0.2)", background: "rgba(1, 4, 9, 0.88)", backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
|
||||
<style>{VIS_OVERRIDE_CSS}</style>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: `1px solid ${GRAPH_THEME.ui.timeline.border}`, minWidth: 80, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: "1px solid rgba(88, 166, 255, 0.15)", minWidth: 80, flexShrink: 0 }}>
|
||||
<button
|
||||
id="temporal-play-btn"
|
||||
onClick={togglePlay}
|
||||
title={isPlaying ? "Pause Evolution" : "Play Evolution"}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? 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" }}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? "#58a6ff" : "rgba(88, 166, 255, 0.35)"}`, background: isPlaying ? "rgba(88, 166, 255, 0.2)" : "rgba(88, 166, 255, 0.06)", color: "#58a6ff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(88, 166, 255, 0.4)" : "none" }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
||||
@@ -182,12 +181,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 ? GRAPH_THEME.ui.timeline.playhead : GRAPH_THEME.ui.timeline.text, fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
|
||||
<span style={{ fontSize: 10, color: isPlaying ? "#58a6ff" : "#8b949e", fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
|
||||
{displayDate}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: GRAPH_THEME.ui.text.subtle, textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
||||
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: "rgba(88, 166, 255, 0.55)", textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
||||
Temporal Scrubber · {minBound.getFullYear()}-{maxBound.getFullYear()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,19 +7,11 @@ export const clickSelectionBehavior: GraphBehavior = {
|
||||
onNodeClick: (context, nodeId) => {
|
||||
context.setHoveredNodeId(nodeId);
|
||||
context.onEdgeSelectionChange("");
|
||||
if (context.getInteractionState().selectedNodeId === nodeId) {
|
||||
context.onNodeSelectionChange("");
|
||||
} else {
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
}
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
},
|
||||
onEdgeClick: (context, edgeId) => {
|
||||
context.setHoveredNodeId(null);
|
||||
if (context.getInteractionState().selectedEdgeId === edgeId) {
|
||||
context.onEdgeSelectionChange("");
|
||||
} else {
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
}
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
},
|
||||
onStageClick: (context) => {
|
||||
context.setHoveredNodeId(null);
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
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();
|
||||
detach: () => {
|
||||
lastPathSignature = "";
|
||||
context.sigma.refresh();
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextPathSignature = interactionState.activePath.join("::");
|
||||
@@ -40,13 +16,7 @@ export function createPathHighlightBehavior(): GraphBehavior {
|
||||
}
|
||||
|
||||
lastPathSignature = nextPathSignature;
|
||||
cancelSweep();
|
||||
context.sigma.refresh();
|
||||
|
||||
// Animate intermediate nodes lighting up sequentially
|
||||
if (interactionState.activePath.length > 2) {
|
||||
scheduleSweep(context.sigma, 0, sweepGeneration);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,18 +44,8 @@ const MAX_REGION_SUMMARIES = 6;
|
||||
const MAX_CENTRALITY_SUMMARIES = 6;
|
||||
const CENTRALITY_ITERATIONS = 24;
|
||||
const MAX_BACKBONE_ANCHORS = 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;
|
||||
};
|
||||
const MAX_BACKBONE_CENTRAL_LINKS = 2;
|
||||
const MAX_BACKBONE_BRIDGES = 4;
|
||||
|
||||
function getNodeLabel(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
@@ -374,61 +364,6 @@ 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>,
|
||||
@@ -444,10 +379,7 @@ 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));
|
||||
@@ -465,7 +397,7 @@ function buildOverviewBackboneSnapshot(
|
||||
.map((summary) => summary.id)
|
||||
.filter((nodeId) => visibleNodeIds.has(nodeId));
|
||||
|
||||
const coreLinkCandidates = new Map<string, BackboneCandidate>();
|
||||
const coreLinkCandidates = new Map<string, { edgeId: string; score: number }>();
|
||||
anchorIds.forEach((anchorId) => {
|
||||
collectNodeIncidentEdges(graphRef, anchorId, visibleNodeIds)
|
||||
.filter((entry) => {
|
||||
@@ -483,88 +415,60 @@ 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;
|
||||
upsertBackboneCandidate(coreLinkCandidates, pairKey, {
|
||||
edgeId: entry.edgeId,
|
||||
source: entry.source,
|
||||
target: entry.target,
|
||||
score,
|
||||
});
|
||||
const current = coreLinkCandidates.get(pairKey);
|
||||
if (!current || score > current.score || (score === current.score && entry.edgeId.localeCompare(current.edgeId) < 0)) {
|
||||
coreLinkCandidates.set(pairKey, { edgeId: entry.edgeId, score });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const bridgeCandidates = new Map<string, BackboneCandidate>();
|
||||
const structuralCandidates = new Map<string, BackboneCandidate>();
|
||||
const bridgeByPair = new Map<string, { edgeId: string; score: number }>();
|
||||
graphRef.forEachEdge((edgeId, attrs, source, target) => {
|
||||
if (!visibleNodeIds.has(source) || !visibleNodeIds.has(target)) {
|
||||
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);
|
||||
const sourceRegion = regionByNode.get(source);
|
||||
const targetRegion = regionByNode.get(target);
|
||||
if (!sourceRegion || !targetRegion || sourceRegion === targetRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pairKey = [sourceId, targetId].sort().join("::");
|
||||
upsertBackboneCandidate(structuralCandidates, pairKey, 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 });
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
[...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));
|
||||
|
||||
const edgeIds = selected
|
||||
.map((entry) => entry.edgeId)
|
||||
.filter((edgeId) => graphRef.hasEdge(edgeId));
|
||||
[...coreLinkCandidates.values()]
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
})
|
||||
.slice(0, MAX_BACKBONE_CENTRAL_LINKS)
|
||||
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
|
||||
|
||||
const edgeIds = [...selectedEdgeIds]
|
||||
.filter((edgeId) => graphRef.hasEdge(edgeId))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
return {
|
||||
ready: edgeIds.length > 0,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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.05)");
|
||||
gradient.addColorStop(0.58, "rgba(255,255,255,0.02)");
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0.22)");
|
||||
gradient.addColorStop(0.58, "rgba(255,255,255,0.08)");
|
||||
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,335 +0,0 @@
|
||||
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,7 +2,6 @@ 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";
|
||||
@@ -54,60 +53,6 @@ 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;
|
||||
@@ -189,16 +134,6 @@ 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;
|
||||
@@ -236,49 +171,6 @@ 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;
|
||||
@@ -410,9 +302,9 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
nodeCoreMix: 0.72,
|
||||
nodeShellAlpha: 0.97,
|
||||
nodeCoreAlpha: 1,
|
||||
edgeBackbone: "rgba(84, 123, 145, 0.24)",
|
||||
edgeStructure: "rgba(49, 63, 78, 0.08)",
|
||||
edgeInspection: "rgba(76, 102, 128, 0.12)",
|
||||
edgeBackbone: "rgba(100, 148, 210, 0.38)",
|
||||
edgeStructure: "rgba(88, 140, 200, 0.28)",
|
||||
edgeInspection: "rgba(110, 165, 230, 0.48)",
|
||||
},
|
||||
accent: {
|
||||
selected: "#F2D288",
|
||||
@@ -425,98 +317,44 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
muted: {
|
||||
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)",
|
||||
edgeOverview: "rgba(82, 100, 124, 0.12)",
|
||||
edgeStructure: "rgba(92, 112, 138, 0.18)",
|
||||
edgeInspection: "rgba(124, 148, 176, 0.26)",
|
||||
edgeFocus: "rgba(160, 186, 218, 0.42)",
|
||||
},
|
||||
background: {
|
||||
canvas: "#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)",
|
||||
canvas: "#07101A",
|
||||
shell: "rgba(8, 15, 26, 0.8)",
|
||||
shellBorder: "rgba(118, 162, 207, 0.14)",
|
||||
shellGlow: "rgba(48, 88, 140, 0.14)",
|
||||
grid: "rgba(92, 126, 170, 0.034)",
|
||||
vignette: "rgba(2, 5, 11, 0.84)",
|
||||
nodeBorder: "#0C1522",
|
||||
},
|
||||
},
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.72,
|
||||
labelThreshold: 0.998,
|
||||
labelBudget: 2,
|
||||
labelThreshold: 0.995,
|
||||
labelBudget: 4,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.62,
|
||||
showBadges: false,
|
||||
showCurves: true,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 0.94,
|
||||
labelThreshold: 0.95,
|
||||
labelBudget: 12,
|
||||
labelThreshold: 0.93,
|
||||
labelBudget: 18,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: false,
|
||||
showCurves: true,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
inspection: {
|
||||
@@ -590,11 +428,11 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
||||
},
|
||||
states: {
|
||||
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 },
|
||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
|
||||
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
},
|
||||
@@ -605,68 +443,6 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
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,
|
||||
@@ -691,14 +467,14 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
edges: {
|
||||
states: {
|
||||
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 },
|
||||
default: { color: "structure", sizeMultiplier: 0.96, minSize: 0.9, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 1.0, minSize: 1.0, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 1.1, minSize: 1.2, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.4, zIndex: 4, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
muted: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
},
|
||||
variants: {
|
||||
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
|
||||
@@ -707,155 +483,6 @@ 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,
|
||||
@@ -1002,7 +629,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(")) {
|
||||
|
||||
@@ -5,14 +5,13 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDistanceVisualState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
GraphLayoutSource,
|
||||
GraphLayoutStatus,
|
||||
GraphRuntimeDiagnosticsSnapshot,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
@@ -36,7 +35,7 @@ export interface GraphSceneEventMap {
|
||||
onEdgeSelect?: (edgeId: string) => void;
|
||||
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
|
||||
onCameraStateChange?: (cameraState: GraphCameraState) => void;
|
||||
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
|
||||
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
|
||||
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
|
||||
onRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
|
||||
}
|
||||
@@ -52,7 +51,6 @@ export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
effectsState: GraphEffectsState;
|
||||
temporalState?: GraphTemporalState | null;
|
||||
isLayoutRunning: boolean;
|
||||
|
||||
@@ -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, type GraphEntityShapeVariant, withAlpha } from "./graphTheme";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
|
||||
type SemanticaNodeDrawData = {
|
||||
x: number;
|
||||
@@ -16,106 +16,39 @@ type SemanticaNodeDrawData = {
|
||||
shellColor?: string;
|
||||
coreScale?: number;
|
||||
borderColor?: string;
|
||||
borderSize?: number;
|
||||
ringColor?: string;
|
||||
ringSize?: number;
|
||||
entityShape?: GraphEntityShapeVariant;
|
||||
entityShapeKind?: number;
|
||||
entityAspectRatio?: number;
|
||||
nodeType?: string;
|
||||
};
|
||||
|
||||
const ENTITY_TOKEN_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
const MINERAL_DISC_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
|
||||
const ENTITY_TOKEN_FRAGMENT_SHADER = /* glsl */ `
|
||||
const MINERAL_DISC_FRAGMENT_SHADER = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec4 v_bodyColor;
|
||||
varying vec4 v_glyphColor;
|
||||
varying vec4 v_outlineColor;
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_outlineSize;
|
||||
varying float v_glyphScale;
|
||||
varying float v_shapeKind;
|
||||
varying float v_aspectRatio;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
float 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;
|
||||
float discMetric(vec2 point) {
|
||||
return length(point);
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 unit = vec2(
|
||||
v_diffVector.x / max(v_radius * v_aspectRatio, 0.0001),
|
||||
v_diffVector.y / max(v_radius, 0.0001)
|
||||
);
|
||||
vec2 unit = v_diffVector / max(v_radius, 0.0001);
|
||||
float metric = discMetric(unit);
|
||||
float aa = (2.4 * u_correctionRatio) / max(v_radius, 1.0);
|
||||
float distance = shapeDistance(unit, v_shapeKind);
|
||||
float alpha = 1.0 - smoothstep(-aa, aa, distance);
|
||||
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, metric);
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
if (alpha <= 0.0) {
|
||||
@@ -130,63 +63,52 @@ void main(void) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
float ringNorm = clamp(v_ringSize / max(v_radius, 1.0), 0.0, 0.45);
|
||||
float ringStart = max(0.0, 1.0 - ringNorm);
|
||||
float coreEdge = clamp(v_coreScale, 0.06, 0.78);
|
||||
float coreBlend = 1.0 - smoothstep(max(coreEdge - 0.14, 0.0), coreEdge, metric);
|
||||
float bodyLight = 1.0 - smoothstep(0.0, 0.82, metric);
|
||||
vec4 color = mix(v_shellColor, v_coreColor, coreBlend);
|
||||
color.rgb += vec3(0.022) * pow(bodyLight, 1.45);
|
||||
|
||||
if (isOutline > 0.0) {
|
||||
color = mix(color, v_outlineColor, isOutline);
|
||||
if (ringNorm > 0.0 && metric >= ringStart) {
|
||||
color = v_ringColor;
|
||||
}
|
||||
|
||||
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 ENTITY_TOKEN_VERTEX_SHADER = /* glsl */ `
|
||||
const MINERAL_DISC_VERTEX_SHADER = /* glsl */ `
|
||||
attribute vec4 a_id;
|
||||
attribute vec2 a_position;
|
||||
attribute float a_size;
|
||||
attribute float a_angle;
|
||||
attribute vec4 a_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;
|
||||
attribute vec4 a_coreColor;
|
||||
attribute vec4 a_shellColor;
|
||||
attribute vec4 a_ringColor;
|
||||
attribute float a_ringSize;
|
||||
attribute float a_coreScale;
|
||||
|
||||
uniform mat3 u_matrix;
|
||||
uniform float u_sizeRatio;
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
varying vec4 v_bodyColor;
|
||||
varying vec4 v_glyphColor;
|
||||
varying vec4 v_outlineColor;
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_outlineSize;
|
||||
varying float v_glyphScale;
|
||||
varying float v_shapeKind;
|
||||
varying float v_aspectRatio;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
|
||||
void main() {
|
||||
float size = a_size * u_correctionRatio / u_sizeRatio * 4.0;
|
||||
float aspect = max(a_aspectRatio, 1.0);
|
||||
vec2 diffVector = size * vec2(cos(a_angle) * aspect, sin(a_angle));
|
||||
vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle));
|
||||
vec2 position = a_position + diffVector;
|
||||
|
||||
gl_Position = vec4(
|
||||
@@ -197,24 +119,22 @@ void main() {
|
||||
|
||||
v_diffVector = diffVector;
|
||||
v_radius = size / 2.0;
|
||||
v_outlineSize = a_outlineSize;
|
||||
v_glyphScale = a_glyphScale;
|
||||
v_shapeKind = a_shapeKind;
|
||||
v_aspectRatio = aspect;
|
||||
v_ringSize = a_ringSize;
|
||||
v_coreScale = a_coreScale;
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
v_color = a_id;
|
||||
#else
|
||||
v_bodyColor = a_bodyColor;
|
||||
v_glyphColor = a_glyphColor;
|
||||
v_outlineColor = a_outlineColor;
|
||||
v_coreColor = a_coreColor;
|
||||
v_shellColor = a_shellColor;
|
||||
v_ringColor = a_ringColor;
|
||||
#endif
|
||||
|
||||
v_color.a *= bias;
|
||||
}
|
||||
`;
|
||||
|
||||
class EntityTokenNodeProgram extends NodeProgram<(typeof ENTITY_TOKEN_UNIFORMS)[number]> {
|
||||
class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[number]> {
|
||||
static readonly ANGLE_1 = 0;
|
||||
static readonly ANGLE_2 = (2 * Math.PI) / 3;
|
||||
static readonly ANGLE_3 = (4 * Math.PI) / 3;
|
||||
@@ -226,52 +146,47 @@ class EntityTokenNodeProgram extends NodeProgram<(typeof ENTITY_TOKEN_UNIFORMS)[
|
||||
getDefinition() {
|
||||
return {
|
||||
VERTICES: 3,
|
||||
VERTEX_SHADER_SOURCE: ENTITY_TOKEN_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: ENTITY_TOKEN_FRAGMENT_SHADER,
|
||||
VERTEX_SHADER_SOURCE: MINERAL_DISC_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: MINERAL_DISC_FRAGMENT_SHADER,
|
||||
METHOD: WebGLRenderingContext.TRIANGLES,
|
||||
UNIFORMS: ENTITY_TOKEN_UNIFORMS,
|
||||
UNIFORMS: MINERAL_DISC_UNIFORMS,
|
||||
ATTRIBUTES: [
|
||||
{ name: "a_position", size: 2, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_size", 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_coreColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_shellColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringSize", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_coreScale", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_id", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
],
|
||||
CONSTANT_ATTRIBUTES: [
|
||||
{ name: "a_angle", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
],
|
||||
CONSTANT_DATA: [
|
||||
[EntityTokenNodeProgram.ANGLE_1],
|
||||
[EntityTokenNodeProgram.ANGLE_2],
|
||||
[EntityTokenNodeProgram.ANGLE_3],
|
||||
[MineralDiscNodeProgram.ANGLE_1],
|
||||
[MineralDiscNodeProgram.ANGLE_2],
|
||||
[MineralDiscNodeProgram.ANGLE_3],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
processVisibleItem(nodeIndex: number, startIndex: number, data: NodeDisplayData & SemanticaNodeDrawData): void {
|
||||
const array = this.array;
|
||||
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);
|
||||
const ringColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
|
||||
|
||||
array[startIndex++] = data.x;
|
||||
array[startIndex++] = data.y;
|
||||
array[startIndex++] = data.size;
|
||||
array[startIndex++] = floatColor(data.color || GRAPH_THEME.palette.overview.nodeCore);
|
||||
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, 0.58));
|
||||
array[startIndex++] = floatColor(outlineColor);
|
||||
array[startIndex++] = outlineSize;
|
||||
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, GRAPH_THEME.palette.overview.nodeShellAlpha));
|
||||
array[startIndex++] = floatColor(ringColor);
|
||||
array[startIndex++] = data.ringSize || 0;
|
||||
array[startIndex++] = data.coreScale ?? 0.22;
|
||||
array[startIndex++] = 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 ENTITY_TOKEN_UNIFORMS)[number]>): void {
|
||||
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof MINERAL_DISC_UNIFORMS)[number]>): void {
|
||||
gl.uniform1f(uniformLocations.u_correctionRatio, params.correctionRatio);
|
||||
gl.uniform1f(uniformLocations.u_sizeRatio, params.sizeRatio);
|
||||
gl.uniformMatrix3fv(uniformLocations.u_matrix, false, params.matrix);
|
||||
@@ -411,7 +326,7 @@ export const drawSemanticaNodeHover: NodeHoverDrawingFunction = (context, rawDat
|
||||
|
||||
export const SEMANTICA_NODE_PROGRAM_CLASSES = {
|
||||
...DEFAULT_NODE_PROGRAM_CLASSES,
|
||||
circle: EntityTokenNodeProgram,
|
||||
circle: MineralDiscNodeProgram,
|
||||
};
|
||||
|
||||
export const SEMANTICA_EDGE_PROGRAM_CLASSES = {
|
||||
|
||||
@@ -12,45 +12,7 @@ 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;
|
||||
@@ -130,51 +92,11 @@ 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;
|
||||
|
||||
@@ -10,11 +10,9 @@ 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";
|
||||
|
||||
@@ -198,15 +196,6 @@ 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,
|
||||
@@ -559,7 +548,6 @@ 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);
|
||||
@@ -587,7 +575,6 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
strokeColor: darkenHex(baseColor, 112),
|
||||
borderColor: darkenHex(baseColor, 112),
|
||||
borderSize: 0.72,
|
||||
entityShape,
|
||||
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
|
||||
} as NodeAttributes,
|
||||
};
|
||||
@@ -611,12 +598,6 @@ 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,
|
||||
@@ -636,7 +617,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: edgeVisualPriority,
|
||||
visualPriority: Math.max(sourcePriority, targetPriority),
|
||||
isBidirectional,
|
||||
edgeFamily: isBidirectional ? "bidirectional" : "line",
|
||||
curveGroup: curveGroupForPair(edge.source, edge.target),
|
||||
|
||||
@@ -1,913 +0,0 @@
|
||||
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)",
|
||||
};
|
||||
@@ -1,915 +0,0 @@
|
||||
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",
|
||||
};
|
||||
@@ -1,574 +0,0 @@
|
||||
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",
|
||||
};
|
||||
@@ -1,638 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,289 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -5,33 +5,13 @@ import {
|
||||
batchMergeEdges,
|
||||
batchMergeNodes,
|
||||
clearGraph,
|
||||
graph,
|
||||
} from "../src/store/graphStore.ts";
|
||||
import {
|
||||
buildGraphAnalyticsSnapshot,
|
||||
computeGraphAnalyticsBase,
|
||||
} from "../src/workspaces/GraphWorkspace/graphAnalytics.ts";
|
||||
import {
|
||||
buildHeatmapRenderSnapshot,
|
||||
buildStructuralDistanceSnapshot,
|
||||
classifyFullGraphEdge,
|
||||
checkGroupedViewAvailability,
|
||||
mapFullEdgeClassToVisualState,
|
||||
resolveDistanceEdgeStyle,
|
||||
resolveDistanceNodeStyle,
|
||||
resolveEdgeElementStyle,
|
||||
resolveEdgeVisualState,
|
||||
resolveDisplayGraph,
|
||||
resolveGroupedDisplayNodeId,
|
||||
resolveGroupedDisplayStateSnapshot,
|
||||
summarizeDistanceBuckets,
|
||||
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||
import {
|
||||
buildGraphStructureCurveCache,
|
||||
evaluateGraphStructureLayerGate,
|
||||
} from "../src/workspaces/GraphWorkspace/graphStructureLayer.ts";
|
||||
import { GRAPH_THEME } from "../src/workspaces/GraphWorkspace/graphTheme.ts";
|
||||
import type { GraphDistanceVisualState, GraphFullEdgeClass, GraphFullEdgeClassCounts } from "../src/workspaces/GraphWorkspace/types.ts";
|
||||
|
||||
function addNode(id: string, semanticGroup = "entity") {
|
||||
batchMergeNodes([
|
||||
@@ -53,10 +33,6 @@ function addNode(id: string, semanticGroup = "entity") {
|
||||
]);
|
||||
}
|
||||
|
||||
function setNodePosition(id: string, x: number, y: number) {
|
||||
graph.mergeNodeAttributes(id, { x, y });
|
||||
}
|
||||
|
||||
function addEdge(id: string, source: string, target: string, weight = 1) {
|
||||
batchMergeEdges([
|
||||
{
|
||||
@@ -80,785 +56,6 @@ test.after(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
const BASE_NODE_STYLE = {
|
||||
color: "#63E6FF",
|
||||
shellColor: "#63E6FF",
|
||||
coreScale: 1,
|
||||
size: 8,
|
||||
forceLabel: false,
|
||||
label: "node",
|
||||
zIndex: 1,
|
||||
hidden: false,
|
||||
borderColor: "#63E6FF",
|
||||
borderSize: 1,
|
||||
nodeVariant: "default",
|
||||
entityShape: "entity",
|
||||
entityShapeKind: 0,
|
||||
entityAspectRatio: 1,
|
||||
showBadge: false,
|
||||
showRing: false,
|
||||
ringSize: 0,
|
||||
showHalo: false,
|
||||
haloColor: "transparent",
|
||||
} as const;
|
||||
|
||||
const BASE_EDGE_STYLE = {
|
||||
hidden: true,
|
||||
color: "#334155",
|
||||
size: 0.5,
|
||||
zIndex: 0,
|
||||
edgeVariant: "line",
|
||||
arrowVisibilityPolicy: "hidden",
|
||||
curveStrength: 0,
|
||||
curvature: 0,
|
||||
} as const;
|
||||
|
||||
function makeDistanceState(overrides: Partial<GraphDistanceVisualState>): GraphDistanceVisualState {
|
||||
return {
|
||||
mode: "off",
|
||||
anchorNodeId: null,
|
||||
anchorLabel: null,
|
||||
maxHops: 2,
|
||||
structuralDistances: {},
|
||||
semanticScores: {},
|
||||
semanticNeighborCount: 0,
|
||||
status: "ready",
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildStructuralDistanceSnapshot returns bounded BFS hop distances", () => {
|
||||
addNode("anchor");
|
||||
addNode("near");
|
||||
addNode("far");
|
||||
addNode("outside");
|
||||
addNode("too-far");
|
||||
addEdge("e-anchor-near", "anchor", "near");
|
||||
addEdge("e-near-far", "near", "far");
|
||||
addEdge("e-far-outside", "far", "outside");
|
||||
addEdge("e-outside-too-far", "outside", "too-far");
|
||||
|
||||
const distances = buildStructuralDistanceSnapshot(graph, "anchor", 3);
|
||||
|
||||
assert.equal(distances.anchor, 0);
|
||||
assert.equal(distances.near, 1);
|
||||
assert.equal(distances.far, 2);
|
||||
assert.equal(distances.outside, 3);
|
||||
assert.equal(distances["too-far"], undefined);
|
||||
});
|
||||
|
||||
test("summarizeDistanceBuckets reports local rings and outside count", () => {
|
||||
const counts = summarizeDistanceBuckets({
|
||||
anchor: 0,
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
}, 6);
|
||||
|
||||
assert.deepEqual(counts, {
|
||||
anchor: 1,
|
||||
oneHop: 1,
|
||||
twoHop: 1,
|
||||
threeHop: 1,
|
||||
outside: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildHeatmapRenderSnapshot caps and deterministically samples large rings", () => {
|
||||
addNode("anchor");
|
||||
for (let index = 0; index < 130; index += 1) {
|
||||
const nodeId = `one-${index}`;
|
||||
addNode(nodeId);
|
||||
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 7, labelPriority: index % 5 });
|
||||
addEdge(`edge-anchor-${nodeId}`, "anchor", nodeId, index % 11);
|
||||
}
|
||||
for (let index = 0; index < 700; index += 1) {
|
||||
const nodeId = `two-${index}`;
|
||||
addNode(nodeId);
|
||||
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 13, labelPriority: index % 3 });
|
||||
addEdge(`edge-one-two-${index}`, `one-${index % 130}`, nodeId, index % 17);
|
||||
}
|
||||
for (let index = 0; index < 950; index += 1) {
|
||||
const nodeId = `three-${index}`;
|
||||
addNode(nodeId);
|
||||
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 19, labelPriority: index % 4 });
|
||||
addEdge(`edge-two-three-${index}`, `two-${index % 700}`, nodeId, index % 23);
|
||||
}
|
||||
|
||||
const distances = buildStructuralDistanceSnapshot(graph, "anchor", 3);
|
||||
const firstSnapshot = buildHeatmapRenderSnapshot(graph, "anchor", distances, 3);
|
||||
const secondSnapshot = buildHeatmapRenderSnapshot(graph, "anchor", distances, 3);
|
||||
|
||||
assert.equal(firstSnapshot.ringCounts.anchor, 1);
|
||||
assert.equal(firstSnapshot.ringCounts.oneHop, 130);
|
||||
assert.equal(firstSnapshot.ringCounts.twoHop, 700);
|
||||
assert.equal(firstSnapshot.ringCounts.threeHop, 950);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.anchor, 1);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.oneHop, 120);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.twoHop, 650);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.threeHop, 900);
|
||||
assert.equal(firstSnapshot.saturationMode, "sampled");
|
||||
assert.deepEqual(firstSnapshot.visibleNodeIds, secondSnapshot.visibleNodeIds);
|
||||
assert.ok(firstSnapshot.visibleNodeIds.includes("anchor"));
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle applies ego muting without mutating graph data", () => {
|
||||
const state = makeDistanceState({
|
||||
mode: "ego",
|
||||
anchorNodeId: "anchor",
|
||||
anchorLabel: "Anchor",
|
||||
maxHops: 2,
|
||||
structuralDistances: { anchor: 0, near: 1 },
|
||||
});
|
||||
|
||||
const anchorStyle = resolveDistanceNodeStyle(GRAPH_THEME, "inspection", BASE_NODE_STYLE, state, "anchor");
|
||||
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "inspection", BASE_NODE_STYLE, state, "outside");
|
||||
|
||||
assert.equal(anchorStyle.forceLabel, true);
|
||||
assert.equal(anchorStyle.label, "Anchor");
|
||||
assert.ok(Number(anchorStyle.size) > BASE_NODE_STYLE.size);
|
||||
assert.equal(outsideStyle.label, "");
|
||||
assert.ok(Number(outsideStyle.size) < BASE_NODE_STYLE.size);
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle applies readable heatmap rings only when ready", () => {
|
||||
const readyState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, one: 1, two: 2, three: 3 },
|
||||
heatmapVisibleNodeIds: ["anchor", "one", "two", "three"],
|
||||
distanceCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 1,
|
||||
twoHop: 1,
|
||||
threeHop: 1,
|
||||
outside: 1,
|
||||
},
|
||||
});
|
||||
const loadingState = makeDistanceState({ ...readyState, status: "loading" });
|
||||
|
||||
const anchorStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "anchor");
|
||||
const oneHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "one");
|
||||
const twoHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "two");
|
||||
const threeHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "three");
|
||||
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "outside");
|
||||
const loadingStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, loadingState, "near");
|
||||
|
||||
assert.notEqual(anchorStyle.color, oneHopStyle.color);
|
||||
assert.notEqual(oneHopStyle.color, twoHopStyle.color);
|
||||
assert.notEqual(twoHopStyle.color, threeHopStyle.color);
|
||||
assert.ok(Number(anchorStyle.size) > Number(oneHopStyle.size));
|
||||
assert.ok(Number(oneHopStyle.size) > Number(twoHopStyle.size));
|
||||
assert.ok(Number(twoHopStyle.size) > Number(threeHopStyle.size));
|
||||
assert.equal(outsideStyle.label, "");
|
||||
assert.ok(Number(outsideStyle.size) < BASE_NODE_STYLE.size);
|
||||
assert.deepEqual(loadingStyle, {});
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle compresses saturated heatmap far rings", () => {
|
||||
const saturatedState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, one: 1, two: 2, three: 3 },
|
||||
heatmapVisibleNodeIds: ["anchor", "one", "two", "three"],
|
||||
distanceCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 32,
|
||||
twoHop: 3350,
|
||||
threeHop: 7412,
|
||||
outside: 3280,
|
||||
},
|
||||
});
|
||||
|
||||
const oneHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "one");
|
||||
const twoHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "two");
|
||||
const threeHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "three");
|
||||
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "outside");
|
||||
|
||||
assert.ok(Number(oneHopStyle.size) > Number(twoHopStyle.size));
|
||||
assert.ok(Number(twoHopStyle.size) > Number(threeHopStyle.size));
|
||||
assert.ok(Number(threeHopStyle.size) > Number(outsideStyle.size));
|
||||
assert.equal(threeHopStyle.label, "");
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle mutes unsampled heatmap nodes instead of coloring them", () => {
|
||||
const sampledState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, rendered: 2, unsampled: 2 },
|
||||
heatmapVisibleNodeIds: ["anchor", "rendered"],
|
||||
distanceCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 0,
|
||||
twoHop: 2,
|
||||
threeHop: 0,
|
||||
outside: 0,
|
||||
},
|
||||
heatmapRenderedRingCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 0,
|
||||
twoHop: 1,
|
||||
threeHop: 0,
|
||||
outside: 0,
|
||||
},
|
||||
heatmapSaturationMode: "sampled",
|
||||
});
|
||||
|
||||
const renderedStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, sampledState, "rendered");
|
||||
const unsampledStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, sampledState, "unsampled");
|
||||
|
||||
assert.notEqual(renderedStyle.color, unsampledStyle.color);
|
||||
assert.ok(Number(renderedStyle.size) > Number(unsampledStyle.size));
|
||||
assert.equal(unsampledStyle.label, "");
|
||||
});
|
||||
|
||||
test("resolveDistanceEdgeStyle reveals structural and semantic context edges", () => {
|
||||
const structuralState = makeDistanceState({
|
||||
mode: "structural",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 2,
|
||||
structuralDistances: { anchor: 0, near: 1 },
|
||||
});
|
||||
const semanticState = makeDistanceState({
|
||||
mode: "semantic",
|
||||
anchorNodeId: "anchor",
|
||||
semanticScores: { semantic: 0.82 },
|
||||
});
|
||||
|
||||
const structuralStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, structuralState, "anchor", "near");
|
||||
const semanticStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, semanticState, "anchor", "semantic");
|
||||
const unrelatedStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, semanticState, "near", "semantic");
|
||||
|
||||
assert.equal(structuralStyle.hidden, false);
|
||||
assert.equal(semanticStyle.hidden, false);
|
||||
assert.deepEqual(unrelatedStyle, {});
|
||||
});
|
||||
|
||||
test("resolveDistanceEdgeStyle suppresses heatmap background edges but preserves context", () => {
|
||||
const heatmapState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, one: 1 },
|
||||
});
|
||||
|
||||
const backgroundStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "backbone");
|
||||
const contextStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "local-context");
|
||||
const pathStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "path");
|
||||
|
||||
assert.equal(backgroundStyle.hidden, true);
|
||||
assert.deepEqual(contextStyle, {});
|
||||
assert.deepEqual(pathStyle, {});
|
||||
});
|
||||
|
||||
test("resolveEdgeVisualState caps selected-node incident edge promotion", () => {
|
||||
const uncappedState = resolveEdgeVisualState(
|
||||
"edge-1",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
assert.equal(uncappedState, "muted");
|
||||
|
||||
const cappedState = resolveEdgeVisualState(
|
||||
"edge-1",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(["edge-1"]),
|
||||
);
|
||||
assert.equal(cappedState, "selected");
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle applies full-graph LOD to directional background edges", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"default",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 1,
|
||||
properties: {},
|
||||
edgeVariant: "directional",
|
||||
visualPriority: 0.1,
|
||||
baseSize: 0.5,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"directional-low-priority",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, true);
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge applies deterministic priority order", () => {
|
||||
const edgeClass = classifyFullGraphEdge(
|
||||
"edge-priority",
|
||||
"source",
|
||||
"target",
|
||||
"inspection",
|
||||
"source",
|
||||
"source",
|
||||
"edge-priority",
|
||||
new Set(["source", "target"]),
|
||||
new Set(["edge-priority"]),
|
||||
new Set(["edge-priority"]),
|
||||
new Set(["edge-priority"]),
|
||||
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
|
||||
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
|
||||
);
|
||||
|
||||
assert.equal(edgeClass, "path");
|
||||
|
||||
const selectedClass = classifyFullGraphEdge(
|
||||
"edge-priority",
|
||||
"source",
|
||||
"target",
|
||||
"inspection",
|
||||
"source",
|
||||
"source",
|
||||
"edge-priority",
|
||||
new Set(["source", "target"]),
|
||||
new Set(),
|
||||
new Set(["edge-priority"]),
|
||||
);
|
||||
|
||||
assert.equal(selectedClass, "selected");
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge separates capped local context from muted hub edges", () => {
|
||||
const mutedClass = classifyFullGraphEdge(
|
||||
"hub-edge",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(mutedClass, "muted");
|
||||
|
||||
const localContextClass = classifyFullGraphEdge(
|
||||
"hub-edge",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(["hub-edge"]),
|
||||
);
|
||||
|
||||
assert.equal(localContextClass, "local-context");
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge marks curated bridge and backbone candidates", () => {
|
||||
const bridgeClass = classifyFullGraphEdge(
|
||||
"curated-bridge",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(["curated-bridge"]),
|
||||
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
|
||||
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
|
||||
);
|
||||
|
||||
assert.equal(bridgeClass, "bridge");
|
||||
|
||||
const backboneClass = classifyFullGraphEdge(
|
||||
"curated-backbone",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(["curated-backbone"]),
|
||||
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
|
||||
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "target", semanticGroup: "gene", properties: {} },
|
||||
);
|
||||
|
||||
assert.equal(backboneClass, "backbone");
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge hides ordinary full-graph overview edges", () => {
|
||||
const edgeClass = classifyFullGraphEdge(
|
||||
"ordinary-edge",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(edgeClass, "hidden");
|
||||
});
|
||||
|
||||
test("mapFullEdgeClassToVisualState renders curated backbone and bridge as backbone", () => {
|
||||
assert.equal(
|
||||
mapFullEdgeClassToVisualState("backbone", { hoveredNodeId: null, hasActiveInteraction: false }),
|
||||
"backbone",
|
||||
);
|
||||
assert.equal(
|
||||
mapFullEdgeClassToVisualState("bridge", { hoveredNodeId: null, hasActiveInteraction: false }),
|
||||
"backbone",
|
||||
);
|
||||
assert.equal(
|
||||
mapFullEdgeClassToVisualState("hidden", { hoveredNodeId: null, hasActiveInteraction: false }),
|
||||
"inactive",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle renders curated full-graph backbone quietly", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"backbone",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 2,
|
||||
properties: {},
|
||||
visualPriority: 0.9,
|
||||
baseSize: 0.8,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"curated-backbone-edge",
|
||||
"backbone",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false);
|
||||
assert.equal(style.type, "line");
|
||||
assert.match(style.color ?? "", /rgba\(.+,\s*0\.08\)/);
|
||||
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.backboneMaxSize);
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle renders high-value bridge as a calm curved teal edge", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"backbone",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 4,
|
||||
properties: {},
|
||||
visualPriority: 0.95,
|
||||
baseSize: 0.9,
|
||||
edgeVariant: "line",
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"curated-bridge-edge",
|
||||
"bridge",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false);
|
||||
assert.equal(style.type, "curve");
|
||||
assert.notEqual(style.type, "arrow");
|
||||
assert.match(style.color ?? "", /rgba\(.+,\s*0\.14\)/);
|
||||
assert.equal(style.curvature, GRAPH_THEME.edges.fullGraphStructure.bridgeCurveStrength);
|
||||
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.bridgeMaxSize);
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle keeps low-priority bridge straight", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"backbone",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 1,
|
||||
properties: {},
|
||||
visualPriority: 0.2,
|
||||
baseSize: 0.9,
|
||||
edgeVariant: "line",
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"low-value-bridge-edge",
|
||||
"bridge",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false);
|
||||
assert.equal(style.type, "line");
|
||||
assert.equal(style.curvature, 0);
|
||||
});
|
||||
|
||||
test("evaluateGraphStructureLayerGate enables only sparse settled full-graph structure", () => {
|
||||
const counts: GraphFullEdgeClassCounts = {
|
||||
hidden: 20,
|
||||
backbone: 6,
|
||||
bridge: 4,
|
||||
"local-context": 0,
|
||||
selected: 0,
|
||||
path: 0,
|
||||
muted: 0,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "grouped",
|
||||
isLayoutRunning: false,
|
||||
edgeDiagnostics: {
|
||||
mode: "grouped",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 10,
|
||||
counts,
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: false, disabledReason: "non-full-mode" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "full",
|
||||
isLayoutRunning: true,
|
||||
edgeDiagnostics: {
|
||||
mode: "full",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 10,
|
||||
counts,
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: false, disabledReason: "layout-running" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "full",
|
||||
isLayoutRunning: false,
|
||||
edgeDiagnostics: {
|
||||
mode: "full",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 24,
|
||||
counts: { ...counts, backbone: 18, bridge: 6 },
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: false, disabledReason: "enough-literal-edges" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "full",
|
||||
isLayoutRunning: false,
|
||||
edgeDiagnostics: {
|
||||
mode: "full",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 10,
|
||||
counts,
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: true, disabledReason: null },
|
||||
);
|
||||
});
|
||||
|
||||
test("buildGraphStructureCurveCache prefers bridges, caps curves, and skips invalid endpoints", () => {
|
||||
addNode("a", "gene");
|
||||
addNode("b", "disease");
|
||||
addNode("c", "gene");
|
||||
addNode("d", "compound");
|
||||
setNodePosition("a", 0, 0);
|
||||
setNodePosition("b", 100, 0);
|
||||
setNodePosition("c", 0, 100);
|
||||
setNodePosition("d", Number.NaN, 100);
|
||||
|
||||
addEdge("backbone-1", "a", "c", 1);
|
||||
graph.mergeEdgeAttributes("backbone-1", { visualPriority: 1 });
|
||||
addEdge("bridge-1", "a", "b", 0.2);
|
||||
graph.mergeEdgeAttributes("bridge-1", { visualPriority: 0.1 });
|
||||
addEdge("selected-1", "b", "c", 1);
|
||||
graph.mergeEdgeAttributes("selected-1", { visualPriority: 1 });
|
||||
addEdge("invalid-bridge", "a", "d", 1);
|
||||
graph.mergeEdgeAttributes("invalid-bridge", { visualPriority: 1 });
|
||||
|
||||
const edgeClasses = new Map<string, GraphFullEdgeClass>([
|
||||
["backbone-1", "backbone"],
|
||||
["bridge-1", "bridge"],
|
||||
["selected-1", "selected"],
|
||||
["invalid-bridge", "bridge"],
|
||||
]);
|
||||
|
||||
const capped = buildGraphStructureCurveCache({
|
||||
graphRef: graph,
|
||||
cacheKey: "test-cache",
|
||||
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
|
||||
maxCurves: 1,
|
||||
curveStrength: 0.12,
|
||||
});
|
||||
|
||||
assert.equal(capped.curves.length, 1);
|
||||
assert.equal(capped.curves[0].edgeId, "bridge-1");
|
||||
assert.equal(capped.bridgeCurveCount, 1);
|
||||
assert.equal(capped.backboneCurveCount, 0);
|
||||
|
||||
const uncapped = buildGraphStructureCurveCache({
|
||||
graphRef: graph,
|
||||
cacheKey: "test-cache-all",
|
||||
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
|
||||
maxCurves: 10,
|
||||
curveStrength: 0.12,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
uncapped.curves.map((curve) => curve.edgeId).sort(),
|
||||
["backbone-1", "bridge-1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEdgeVisualState suppresses automatic overview backbone in clean baseline", () => {
|
||||
const state = resolveEdgeVisualState(
|
||||
"overview-backbone-high-priority",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(state, "inactive");
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle keeps full-graph selected and path edges controlled", () => {
|
||||
const selectedStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
"selected",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 2,
|
||||
properties: {},
|
||||
visualPriority: 0.9,
|
||||
baseSize: 0.8,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"selected-context-edge",
|
||||
);
|
||||
const pathStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
"path",
|
||||
{
|
||||
edgeType: "causes",
|
||||
weight: 2,
|
||||
properties: {},
|
||||
visualPriority: 0.9,
|
||||
baseSize: 0.8,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"path-context-edge",
|
||||
);
|
||||
|
||||
assert.equal(selectedStyle.hidden, false);
|
||||
assert.match(selectedStyle.color ?? "", /rgba\(.+,\s*0\.6\)/);
|
||||
assert.equal(pathStyle.hidden, false);
|
||||
assert.match(pathStyle.color ?? "", /rgba\(.+,\s*0\.76\)/);
|
||||
});
|
||||
|
||||
test("buildGraphAnalyticsSnapshot emits a readable capped overview backbone", () => {
|
||||
const semanticGroups = ["gene/protein", "disease", "drug", "pathway"];
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
addNode(`n${index}`, semanticGroups[index % semanticGroups.length]);
|
||||
}
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let sourceIndex = 0; sourceIndex < 16; sourceIndex += 1) {
|
||||
for (let offset = 1; offset <= 3; offset += 1) {
|
||||
const targetIndex = (sourceIndex + offset * 3) % 16;
|
||||
if (sourceIndex === targetIndex) {
|
||||
continue;
|
||||
}
|
||||
addEdge(`ambient-edge-${edgeIndex}`, `n${sourceIndex}`, `n${targetIndex}`, 1 + (edgeIndex % 5));
|
||||
edgeIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const base = computeGraphAnalyticsBase(graph, {
|
||||
computeCommunities: false,
|
||||
computeCentrality: true,
|
||||
});
|
||||
const analytics = buildGraphAnalyticsSnapshot({
|
||||
graphRef: graph,
|
||||
interactionState: {
|
||||
hoveredNodeId: null,
|
||||
selectedNodeId: "",
|
||||
selectedEdgeId: "",
|
||||
focusedNodeId: "",
|
||||
activePath: [],
|
||||
activePathEdgeIds: [],
|
||||
viewMode: "full",
|
||||
zoomTier: "overview",
|
||||
isLayoutRunning: false,
|
||||
},
|
||||
base,
|
||||
visibleNodeIds: graph.nodes(),
|
||||
});
|
||||
|
||||
assert.equal(analytics.overviewBackbone.ready, true);
|
||||
assert.ok(analytics.overviewBackbone.edgeIds.length > 6);
|
||||
assert.ok(analytics.overviewBackbone.edgeIds.length <= 128);
|
||||
});
|
||||
|
||||
test("resolveDisplayGraph bundles parallel edges in full view", () => {
|
||||
addNode("a");
|
||||
addNode("b");
|
||||
@@ -1060,4 +257,3 @@ test("checkGroupedViewAvailability returns available when communities exist", ()
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.reason, null);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
mkdocs>=1.5.0
|
||||
mkdocs-material>=9.7.6
|
||||
mkdocs-material>=9.4.0
|
||||
mkdocs-minify-plugin>=0.7.0
|
||||
mkdocs-mermaid2-plugin>=1.0.0
|
||||
pymdown-extensions>=10.21.2
|
||||
pymdown-extensions>=10.0
|
||||
|
||||
mkdocstrings[python]>=0.24.0
|
||||
mkdocs-jupyter>=0.24.0
|
||||
|
||||
@@ -74,7 +74,6 @@ Production Use Cases:
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.logging import get_logger
|
||||
from .agent_memory import AgentMemory
|
||||
from .context_retriever import ContextRetriever, RetrievedContext
|
||||
@@ -508,10 +507,6 @@ class AgentContext:
|
||||
include_relationships: bool = False,
|
||||
expand_graph: bool = True,
|
||||
deduplicate: bool = True,
|
||||
anchor_node: Optional[str] = None,
|
||||
max_hops: Optional[int] = None,
|
||||
proximity_weight: float = 0.0,
|
||||
min_confidence_decay: float = 0.0,
|
||||
**kwargs,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -565,33 +560,17 @@ class AgentContext:
|
||||
**kwargs,
|
||||
)
|
||||
# Convert RetrievedContext to dicts
|
||||
result_dicts = [
|
||||
return [
|
||||
self._context_to_dict(r, include_entities, include_relationships)
|
||||
for r in results
|
||||
]
|
||||
return self._apply_proximity_metadata(
|
||||
result_dicts,
|
||||
anchor_node=anchor_node,
|
||||
max_hops=max_hops,
|
||||
proximity_weight=proximity_weight,
|
||||
min_confidence_decay=min_confidence_decay,
|
||||
max_results=max_results,
|
||||
)
|
||||
else:
|
||||
# Simple RAG: Use AgentMemory (vector + memory)
|
||||
results = self._memory.retrieve(
|
||||
query, max_results=max_results, min_score=min_score, **kwargs
|
||||
)
|
||||
# Convert to dicts
|
||||
result_dicts = [self._memory_to_dict(r) for r in results]
|
||||
return self._apply_proximity_metadata(
|
||||
result_dicts,
|
||||
anchor_node=anchor_node,
|
||||
max_hops=max_hops,
|
||||
proximity_weight=proximity_weight,
|
||||
min_confidence_decay=min_confidence_decay,
|
||||
max_results=max_results,
|
||||
)
|
||||
return [self._memory_to_dict(r) for r in results]
|
||||
|
||||
def query_with_reasoning(
|
||||
self,
|
||||
@@ -835,77 +814,6 @@ class AgentContext:
|
||||
|
||||
return result
|
||||
|
||||
def _apply_proximity_metadata(
|
||||
self,
|
||||
results: List[Dict[str, Any]],
|
||||
anchor_node: Optional[str] = None,
|
||||
max_hops: Optional[int] = None,
|
||||
proximity_weight: float = 0.0,
|
||||
min_confidence_decay: float = 0.0,
|
||||
max_results: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Enrich retrieval results with graph distance from an anchor node."""
|
||||
if not anchor_node or not self.knowledge_graph:
|
||||
return results
|
||||
if not hasattr(self.knowledge_graph, "get_neighbor_distances"):
|
||||
return results
|
||||
|
||||
search_hops = max_hops if max_hops is not None else 10
|
||||
distances = self.knowledge_graph.get_neighbor_distances(
|
||||
anchor_node,
|
||||
hops=search_hops,
|
||||
min_confidence=min_confidence_decay,
|
||||
)
|
||||
by_node_id = {item.get("id"): item for item in distances}
|
||||
if anchor_node:
|
||||
by_node_id[anchor_node] = {
|
||||
"id": anchor_node,
|
||||
"hop": 0,
|
||||
"confidence_decay": 1.0,
|
||||
"distance_band": "direct",
|
||||
"path_to_anchor": [anchor_node],
|
||||
}
|
||||
|
||||
enriched: List[Dict[str, Any]] = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata") or {}
|
||||
result_id = (
|
||||
result.get("id")
|
||||
or metadata.get("node_id")
|
||||
or metadata.get("id")
|
||||
or metadata.get("memory_id")
|
||||
)
|
||||
distance = by_node_id.get(result_id)
|
||||
if not distance:
|
||||
if max_hops is not None or min_confidence_decay > 0.0:
|
||||
continue
|
||||
enriched.append(result)
|
||||
continue
|
||||
|
||||
hop_distance = distance.get("hop")
|
||||
if max_hops is not None and hop_distance is not None and hop_distance > max_hops:
|
||||
continue
|
||||
|
||||
proximity_score = 1.0 if hop_distance == 0 else 1.0 / float(hop_distance or 1)
|
||||
score = float(result.get("score", 0.0))
|
||||
bounded_weight = min(max(float(proximity_weight), 0.0), 1.0)
|
||||
combined_score = (1.0 - bounded_weight) * score + bounded_weight * proximity_score
|
||||
enriched_result = {
|
||||
**result,
|
||||
"graph_node_id": result_id,
|
||||
"hop_distance": hop_distance,
|
||||
"confidence_decay": distance.get("confidence_decay"),
|
||||
"distance_band": distance.get("distance_band"),
|
||||
"path_to_anchor": distance.get("path_to_anchor"),
|
||||
"proximity_score": proximity_score,
|
||||
"combined_score": combined_score,
|
||||
}
|
||||
enriched.append(enriched_result)
|
||||
|
||||
if proximity_weight > 0:
|
||||
enriched.sort(key=lambda item: item.get("combined_score", item.get("score", 0.0)), reverse=True)
|
||||
return enriched[:max_results] if max_results is not None else enriched
|
||||
|
||||
def _memory_to_dict(self, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert memory result to dict."""
|
||||
return {
|
||||
@@ -2320,10 +2228,7 @@ class AgentContext:
|
||||
category: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
use_kg_features: bool = True,
|
||||
similarity_weights: Optional[Dict[str, float]] = None,
|
||||
anchor_decision_id: Optional[str] = None,
|
||||
max_causal_hops: Optional[int] = None,
|
||||
min_confidence_decay: float = 0.0,
|
||||
similarity_weights: Optional[Dict[str, float]] = None
|
||||
) -> List[Decision]:
|
||||
"""
|
||||
Find precedents using advanced KG and vector store features.
|
||||
@@ -2343,7 +2248,7 @@ class AgentContext:
|
||||
|
||||
try:
|
||||
if hasattr(self._decision_query, 'find_precedents_hybrid'):
|
||||
precedents = self._decision_query.find_precedents_hybrid(
|
||||
return self._decision_query.find_precedents_hybrid(
|
||||
scenario=scenario,
|
||||
category=category,
|
||||
limit=limit,
|
||||
@@ -2352,116 +2257,11 @@ class AgentContext:
|
||||
)
|
||||
else:
|
||||
# Fallback to basic method
|
||||
precedents = self.find_precedents(scenario, category, limit)
|
||||
return self._apply_causal_proximity_to_precedents(
|
||||
precedents,
|
||||
anchor_decision_id=anchor_decision_id,
|
||||
max_causal_hops=max_causal_hops,
|
||||
min_confidence_decay=min_confidence_decay,
|
||||
limit=limit,
|
||||
)
|
||||
return self.find_precedents(scenario, category, limit)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to find advanced precedents ({type(e).__name__})")
|
||||
return []
|
||||
|
||||
def _apply_causal_proximity_to_precedents(
|
||||
self,
|
||||
precedents: List[Decision],
|
||||
anchor_decision_id: Optional[str] = None,
|
||||
max_causal_hops: Optional[int] = None,
|
||||
min_confidence_decay: float = 0.0,
|
||||
limit: int = 10,
|
||||
) -> List[Decision]:
|
||||
"""Attach causal-distance metadata to precedents and optionally filter."""
|
||||
if not anchor_decision_id or not self.knowledge_graph:
|
||||
return precedents
|
||||
|
||||
causal_types = ["causes", "influences", "leads_to", "supports"]
|
||||
max_hops = max_causal_hops if max_causal_hops is not None else 10
|
||||
distance_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
if hasattr(self.knowledge_graph, "get_neighbor_distances"):
|
||||
for item in self.knowledge_graph.get_neighbor_distances(
|
||||
anchor_decision_id,
|
||||
hops=max_hops,
|
||||
relationship_types=causal_types,
|
||||
min_confidence=min_confidence_decay,
|
||||
):
|
||||
distance_by_id[item.get("id")] = item
|
||||
|
||||
annotated: List[Decision] = []
|
||||
for decision in precedents:
|
||||
decision_id = getattr(decision, "decision_id", None)
|
||||
distance = distance_by_id.get(decision_id)
|
||||
if distance is None and hasattr(self.knowledge_graph, "trace_decision_causality"):
|
||||
distance = self._distance_from_causality_trace(anchor_decision_id, decision_id, max_hops)
|
||||
|
||||
if distance is None:
|
||||
if max_causal_hops is not None or min_confidence_decay > 0.0:
|
||||
continue
|
||||
setattr(decision, "causal_hop_distance", None)
|
||||
setattr(decision, "path_confidence_decay", None)
|
||||
setattr(decision, "distance_band", None)
|
||||
annotated.append(decision)
|
||||
continue
|
||||
|
||||
hop_distance = distance.get("hop", distance.get("hop_count"))
|
||||
confidence_decay = distance.get("confidence_decay")
|
||||
if max_causal_hops is not None and hop_distance is not None and hop_distance > max_causal_hops:
|
||||
continue
|
||||
if confidence_decay is not None and confidence_decay < min_confidence_decay:
|
||||
continue
|
||||
|
||||
setattr(decision, "causal_hop_distance", hop_distance)
|
||||
setattr(decision, "path_confidence_decay", confidence_decay)
|
||||
setattr(decision, "distance_band", distance.get("distance_band"))
|
||||
annotated.append(decision)
|
||||
|
||||
annotated.sort(
|
||||
key=lambda decision: (
|
||||
getattr(decision, "causal_hop_distance", None) is None,
|
||||
getattr(decision, "causal_hop_distance", 10**9) or 10**9,
|
||||
-(getattr(decision, "path_confidence_decay", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
return annotated[:limit]
|
||||
|
||||
def _distance_from_causality_trace(
|
||||
self,
|
||||
anchor_decision_id: str,
|
||||
target_decision_id: Optional[str],
|
||||
max_hops: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Infer anchor-to-target distance from ContextGraph causality reports."""
|
||||
if not target_decision_id:
|
||||
return None
|
||||
try:
|
||||
chains = self.knowledge_graph.trace_decision_causality(target_decision_id, max_depth=max_hops)
|
||||
except Exception:
|
||||
return None
|
||||
best: Optional[Dict[str, Any]] = None
|
||||
for chain in chains:
|
||||
hops = chain.get("hops", chain) if isinstance(chain, dict) else chain
|
||||
if not hops:
|
||||
continue
|
||||
starts_at_anchor = hops[0].get("from") == anchor_decision_id
|
||||
ends_at_target = hops[-1].get("to") == target_decision_id
|
||||
if starts_at_anchor and ends_at_target:
|
||||
candidate = {
|
||||
"hop_count": len(hops),
|
||||
"confidence_decay": chain.get("confidence_decay") if isinstance(chain, dict) else None,
|
||||
"distance_band": chain.get("distance_band") if isinstance(chain, dict) else None,
|
||||
}
|
||||
if candidate["confidence_decay"] is None:
|
||||
decay = 1.0
|
||||
for hop in hops:
|
||||
decay *= float(hop.get("edge_weight", 1.0))
|
||||
candidate["confidence_decay"] = decay
|
||||
if candidate["distance_band"] is None:
|
||||
candidate["distance_band"] = classify_path_distance(candidate["hop_count"])
|
||||
if best is None or candidate["hop_count"] < best["hop_count"]:
|
||||
best = candidate
|
||||
return best
|
||||
|
||||
|
||||
def analyze_decision_influence(self, decision_id: str, max_depth: int = 3) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze decision influence using advanced graph algorithms.
|
||||
|
||||
@@ -64,7 +64,6 @@ from typing import Any, Dict, List, Optional, Set
|
||||
from collections import deque
|
||||
|
||||
from ..graph_store import GraphStore
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.logging import get_logger
|
||||
from .decision_models import Decision
|
||||
|
||||
@@ -678,102 +677,3 @@ class CausalChainAnalyzer:
|
||||
else:
|
||||
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0))
|
||||
return decisions
|
||||
|
||||
def interpret_causal_distance(
|
||||
self,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Traverse only causal-typed edges and return a structured distance report.
|
||||
|
||||
Returns a dict matching CausalDistanceReport with keys:
|
||||
source_id, target_id, causal_path, causal_hop_count,
|
||||
intermediate_decisions, confidence_decay, weakest_link, interpretation
|
||||
"""
|
||||
from collections import deque as _deque
|
||||
|
||||
CAUSAL_TYPES = {"causes", "influences", "leads_to", "supports",
|
||||
"CAUSED", "INFLUENCED", "PRECEDENT_FOR"}
|
||||
|
||||
graph = self.graph_store
|
||||
|
||||
# ContextGraph-native BFS over causal edges
|
||||
if hasattr(graph, "nodes") and hasattr(graph, "_adjacency"):
|
||||
if source_id not in graph.nodes:
|
||||
return self._unreachable_report(source_id, target_id)
|
||||
|
||||
queue = _deque([(source_id, [source_id], 1.0, None)])
|
||||
visited: Set[str] = {source_id}
|
||||
|
||||
while queue:
|
||||
current_id, path, decay, weakest = queue.popleft()
|
||||
if current_id == target_id:
|
||||
hop_count = len(path) - 1
|
||||
intermediates = [
|
||||
n for n in path[1:-1]
|
||||
if str(getattr(graph.nodes.get(n), "node_type", "")).lower() == "decision"
|
||||
]
|
||||
band = classify_path_distance(hop_count)
|
||||
interp = self._causal_interpretation(hop_count, decay, band)
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"causal_path": path,
|
||||
"causal_hop_count": hop_count,
|
||||
"intermediate_decisions": intermediates,
|
||||
"confidence_decay": round(decay, 6),
|
||||
"weakest_link": weakest,
|
||||
"interpretation": interp,
|
||||
}
|
||||
|
||||
with graph._lock:
|
||||
outgoing = list(graph._adjacency.get(current_id, []))
|
||||
|
||||
for edge in outgoing:
|
||||
if edge.edge_type not in CAUSAL_TYPES:
|
||||
continue
|
||||
nxt = edge.target_id
|
||||
if nxt in visited:
|
||||
continue
|
||||
visited.add(nxt)
|
||||
new_decay = decay * edge.weight
|
||||
new_weakest = weakest
|
||||
if weakest is None or edge.weight < weakest.get("edge_weight", 1.0):
|
||||
new_weakest = {"source": current_id, "target": nxt, "edge_weight": edge.weight}
|
||||
queue.append((nxt, path + [nxt], new_decay, new_weakest))
|
||||
|
||||
return self._unreachable_report(source_id, target_id)
|
||||
|
||||
# GraphStore fallback — return not-reachable; callers can use get_causal_chain instead
|
||||
return self._unreachable_report(source_id, target_id)
|
||||
|
||||
@staticmethod
|
||||
def _causal_interpretation(hop_count: int, decay: float, band: str) -> str:
|
||||
if band == "direct":
|
||||
base = f"Direct cause with confidence {decay:.2f}."
|
||||
elif band == "near":
|
||||
base = (
|
||||
f"Mediated through {hop_count - 1} decision(s); "
|
||||
f"confidence decays to {decay:.2f}"
|
||||
)
|
||||
base += " — moderate evidence." if decay > 0.4 else " — weak evidence."
|
||||
else:
|
||||
base = (
|
||||
f"Distal influence across {hop_count} causal steps; "
|
||||
f"confidence near {decay:.2f} — weak signal."
|
||||
)
|
||||
return base
|
||||
|
||||
@staticmethod
|
||||
def _unreachable_report(source_id: str, target_id: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"causal_path": [],
|
||||
"causal_hop_count": 0,
|
||||
"intermediate_decisions": [],
|
||||
"confidence_decay": 0.0,
|
||||
"weakest_link": None,
|
||||
"interpretation": "No causal path found between the two nodes.",
|
||||
}
|
||||
|
||||
@@ -116,7 +116,6 @@ import uuid
|
||||
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from .entity_linker import EntityLinker
|
||||
|
||||
# Optional imports for advanced features
|
||||
@@ -131,13 +130,6 @@ except ImportError:
|
||||
KG_AVAILABLE = False
|
||||
|
||||
|
||||
class _CausalChain(dict):
|
||||
"""Dict response that still iterates over hops for legacy callers."""
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.get("hops", []))
|
||||
|
||||
|
||||
def _parse_iso_dt(value: str) -> Optional[datetime]:
|
||||
"""Parse an ISO datetime string into a tz-naive UTC datetime.
|
||||
|
||||
@@ -747,7 +739,6 @@ class ContextGraph:
|
||||
min_weight: float = 0.0,
|
||||
skip: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
include_distance_metadata: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get neighbors of a node.
|
||||
@@ -771,11 +762,11 @@ class ContextGraph:
|
||||
|
||||
neighbors: List[Dict[str, Any]] = []
|
||||
visited = {node_id}
|
||||
queue = deque([(node_id, 0, [node_id], 1.0)])
|
||||
queue = deque([(node_id, 0)])
|
||||
rel_filter = set(relationship_types) if relationship_types else None
|
||||
|
||||
while queue:
|
||||
current_id, current_hop, path_so_far, decay_so_far = queue.popleft()
|
||||
current_id, current_hop = queue.popleft()
|
||||
if current_hop >= hops:
|
||||
continue
|
||||
|
||||
@@ -789,59 +780,26 @@ class ContextGraph:
|
||||
if neighbor_id in visited:
|
||||
continue
|
||||
visited.add(neighbor_id)
|
||||
next_hop = current_hop + 1
|
||||
next_decay = decay_so_far * edge.weight
|
||||
next_path = path_so_far + [neighbor_id]
|
||||
queue.append((neighbor_id, next_hop, next_path, next_decay))
|
||||
queue.append((neighbor_id, current_hop + 1))
|
||||
|
||||
node = self.nodes.get(neighbor_id)
|
||||
if not node:
|
||||
continue
|
||||
entry: Dict[str, Any] = {
|
||||
"id": node.node_id,
|
||||
"type": node.node_type,
|
||||
"content": node.content,
|
||||
"relationship": edge.edge_type,
|
||||
"weight": edge.weight,
|
||||
"hop": next_hop,
|
||||
}
|
||||
if include_distance_metadata:
|
||||
entry["distance_band"] = classify_path_distance(next_hop)
|
||||
entry["confidence_decay"] = next_decay
|
||||
entry["path_to_anchor"] = next_path
|
||||
neighbors.append(entry)
|
||||
neighbors.append(
|
||||
{
|
||||
"id": node.node_id,
|
||||
"type": node.node_type,
|
||||
"content": node.content,
|
||||
"relationship": edge.edge_type,
|
||||
"weight": edge.weight,
|
||||
"hop": current_hop + 1,
|
||||
}
|
||||
)
|
||||
|
||||
if limit is not None:
|
||||
return neighbors[skip: skip + limit]
|
||||
return neighbors[skip:]
|
||||
|
||||
def get_neighbor_distances(
|
||||
self,
|
||||
node_id: str,
|
||||
hops: int = 3,
|
||||
relationship_types: Optional[List[str]] = None,
|
||||
min_confidence: float = 0.0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return neighbors with distance metadata, filtered by confidence decay.
|
||||
|
||||
Results are ordered by nearest hop first, then by strongest path confidence.
|
||||
"""
|
||||
neighbors = self.get_neighbors(
|
||||
node_id,
|
||||
hops=hops,
|
||||
relationship_types=relationship_types,
|
||||
include_distance_metadata=True,
|
||||
)
|
||||
filtered = [
|
||||
item for item in neighbors
|
||||
if item.get("confidence_decay", 0.0) >= min_confidence
|
||||
]
|
||||
return sorted(
|
||||
filtered,
|
||||
key=lambda item: (item.get("hop", 0), -item.get("confidence_decay", 0.0)),
|
||||
)
|
||||
|
||||
def query(
|
||||
self, query: str, skip: int = 0, limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -1229,90 +1187,6 @@ class ContextGraph:
|
||||
other_graph, _, target_node_id = self._linked_graphs[link_id]
|
||||
return other_graph, target_node_id
|
||||
|
||||
def cross_graph_path(
|
||||
self,
|
||||
source_node_id: str,
|
||||
target_graph: "ContextGraph",
|
||||
target_node_id: str,
|
||||
max_hops: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Find the shortest path across linked ContextGraph instances.
|
||||
"""
|
||||
start = (self.graph_id, source_node_id)
|
||||
goal = (target_graph.graph_id, target_node_id)
|
||||
if source_node_id not in self.nodes or target_node_id not in target_graph.nodes:
|
||||
return {
|
||||
"path": [],
|
||||
"hop_count": 0,
|
||||
"cross_graph_links_used": 0,
|
||||
"confidence_decay": 0.0,
|
||||
"distance_band": classify_path_distance(max_hops + 1),
|
||||
"reachable": False,
|
||||
}
|
||||
|
||||
queue = deque([(self, source_node_id, [start], 0, 1.0, 0)])
|
||||
visited = {start}
|
||||
|
||||
while queue:
|
||||
graph, current_id, path, hop_count, decay, links_used = queue.popleft()
|
||||
current_key = (graph.graph_id, current_id)
|
||||
if current_key == goal:
|
||||
return {
|
||||
"path": path,
|
||||
"hop_count": hop_count,
|
||||
"cross_graph_links_used": links_used,
|
||||
"confidence_decay": decay,
|
||||
"distance_band": classify_path_distance(hop_count),
|
||||
"reachable": True,
|
||||
}
|
||||
if hop_count >= max_hops:
|
||||
continue
|
||||
|
||||
with graph._lock:
|
||||
outgoing_edges = list(graph._adjacency.get(current_id, []))
|
||||
|
||||
for edge in outgoing_edges:
|
||||
marker = graph.nodes.get(edge.target_id)
|
||||
link_id = None
|
||||
if marker and marker.node_type == "cross_graph_link":
|
||||
link_id = marker.metadata.get("link_id")
|
||||
|
||||
if link_id:
|
||||
try:
|
||||
next_graph, next_node_id = graph.navigate_to(link_id)
|
||||
except KeyError:
|
||||
continue
|
||||
next_key = (next_graph.graph_id, next_node_id)
|
||||
next_links_used = links_used + 1
|
||||
else:
|
||||
next_graph, next_node_id = graph, edge.target_id
|
||||
next_key = (graph.graph_id, edge.target_id)
|
||||
next_links_used = links_used
|
||||
|
||||
if next_key in visited:
|
||||
continue
|
||||
visited.add(next_key)
|
||||
queue.append(
|
||||
(
|
||||
next_graph,
|
||||
next_node_id,
|
||||
path + [next_key],
|
||||
hop_count + 1,
|
||||
decay * edge.weight,
|
||||
next_links_used,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"path": [],
|
||||
"hop_count": 0,
|
||||
"cross_graph_links_used": 0,
|
||||
"confidence_decay": 0.0,
|
||||
"distance_band": classify_path_distance(max_hops + 1),
|
||||
"reachable": False,
|
||||
}
|
||||
|
||||
def resolve_links(self, graphs: Dict[str, "ContextGraph"]) -> int:
|
||||
"""
|
||||
Reconnect cross-graph links after a :meth:`load_from_file` call.
|
||||
@@ -2678,14 +2552,13 @@ class ContextGraph:
|
||||
# Calculate influence scores
|
||||
influence_scores = {}
|
||||
for influenced_id in direct_influence | indirect_influence:
|
||||
influence_scores[influenced_id] = self._calculate_decision_influence_score(
|
||||
decision_id, influenced_id
|
||||
)
|
||||
score = self._calculate_decision_influence_score(decision_id, influenced_id)
|
||||
influence_scores[influenced_id] = score
|
||||
|
||||
# Sort by influence score
|
||||
sorted_influence = sorted(
|
||||
influence_scores.items(),
|
||||
key=lambda x: x[1].get("score", 0.0),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
@@ -2703,22 +2576,11 @@ class ContextGraph:
|
||||
"direct_influence": [_enrich(did) for did in direct_influence],
|
||||
"indirect_influence": [_enrich(did) for did in indirect_influence],
|
||||
"influence_scores": [
|
||||
{
|
||||
**_enrich(did),
|
||||
"score": details.get("score", 0.0),
|
||||
"score_breakdown": {
|
||||
"entity_overlap": details.get("entity_score", 0.0),
|
||||
"category_match": details.get("category_score", 0.0),
|
||||
"temporal_proximity": details.get("time_score", 0.0),
|
||||
},
|
||||
"is_direct": did in direct_influence,
|
||||
}
|
||||
for did, details in sorted_influence
|
||||
{**_enrich(did), "score": score}
|
||||
for did, score in sorted_influence
|
||||
],
|
||||
"total_influenced": len(influence_scores),
|
||||
"max_influence_score": max(
|
||||
details.get("score", 0.0) for details in influence_scores.values()
|
||||
) if influence_scores else 0.0
|
||||
"max_influence_score": max(influence_scores.values()) if influence_scores else 0.0
|
||||
}
|
||||
|
||||
def get_decision_insights(self) -> Dict[str, Any]:
|
||||
@@ -2815,17 +2677,15 @@ class ContextGraph:
|
||||
|
||||
for cause_id in potential_causes:
|
||||
cause_dec = self._decisions.get(cause_id, {})
|
||||
edge_weight = float(cause_dec.get("confidence", 1.0))
|
||||
hop = {
|
||||
"from": cause_id,
|
||||
"from_scenario": cause_dec.get("scenario", ""),
|
||||
"to": current_id,
|
||||
"to_scenario": current_decision.get("scenario", ""),
|
||||
"type": "influences",
|
||||
"edge_weight": edge_weight,
|
||||
}
|
||||
cause_path = path + [hop]
|
||||
causal_chain.append(self._build_causal_chain_report(list(reversed(cause_path))))
|
||||
causal_chain.append(cause_path)
|
||||
trace_recursive(cause_id, depth + 1, cause_path)
|
||||
|
||||
trace_recursive(decision_id, 0, [])
|
||||
@@ -3082,49 +2942,11 @@ class ContextGraph:
|
||||
self.logger.warning(f"Indirect influence analysis failed: {e}")
|
||||
return set()
|
||||
|
||||
def _build_causal_chain_report(self, hops: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Build an auditable causal-chain response from hop records."""
|
||||
hop_count = len(hops)
|
||||
confidence_decay = 1.0
|
||||
weakest_link = None
|
||||
for hop in hops:
|
||||
edge_weight = float(hop.get("edge_weight", 1.0))
|
||||
confidence_decay *= edge_weight
|
||||
if weakest_link is None or edge_weight < float(weakest_link.get("edge_weight", 1.0)):
|
||||
weakest_link = hop
|
||||
|
||||
if hop_count <= 1:
|
||||
interpretation = f"Direct influence with confidence {confidence_decay:.2f}."
|
||||
elif confidence_decay > 0.7:
|
||||
interpretation = (
|
||||
f"Mediated through {hop_count - 1} step(s) with high confidence "
|
||||
f"({confidence_decay:.2f})."
|
||||
)
|
||||
elif confidence_decay > 0.4:
|
||||
interpretation = (
|
||||
f"Mediated through {hop_count - 1} step(s) - confidence decays "
|
||||
f"to {confidence_decay:.2f}."
|
||||
)
|
||||
else:
|
||||
interpretation = (
|
||||
f"Distal influence across {hop_count} causal steps; confidence "
|
||||
f"{confidence_decay:.2f} is weak evidence."
|
||||
)
|
||||
|
||||
return _CausalChain({
|
||||
"hops": hops,
|
||||
"hop_count": hop_count,
|
||||
"confidence_decay": confidence_decay,
|
||||
"weakest_link": weakest_link,
|
||||
"distance_band": classify_path_distance(hop_count),
|
||||
"interpretation": interpretation,
|
||||
})
|
||||
|
||||
def _calculate_decision_influence_score(self, source_id: str, target_id: str) -> Dict[str, float]:
|
||||
def _calculate_decision_influence_score(self, source_id: str, target_id: str) -> float:
|
||||
"""Calculate influence score between two decisions."""
|
||||
try:
|
||||
if not hasattr(self, '_decisions'):
|
||||
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
|
||||
return 0.0
|
||||
|
||||
source_decision = self._decisions[source_id]
|
||||
target_decision = self._decisions[target_id]
|
||||
@@ -3143,16 +2965,11 @@ class ContextGraph:
|
||||
# Combined score
|
||||
combined_score = 0.5 * entity_score + 0.3 * category_score + 0.2 * time_score
|
||||
|
||||
return {
|
||||
"score": combined_score,
|
||||
"entity_score": entity_score,
|
||||
"category_score": category_score,
|
||||
"time_score": time_score,
|
||||
}
|
||||
return combined_score
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Influence score calculation failed: {e}")
|
||||
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
|
||||
return 0.0
|
||||
|
||||
def _get_decision_temporal_analysis(self) -> Dict[str, Any]:
|
||||
"""Get temporal analysis of decisions."""
|
||||
|
||||
@@ -98,7 +98,6 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
from .routes.enrich import router as enrich_router
|
||||
from .routes.export_import import router as export_import_router
|
||||
from .routes.graph import router as graph_router
|
||||
from .routes.ontology import router as ontology_router
|
||||
from .routes.provenance import router as provenance_router
|
||||
from .routes.sparql import router as sparql_router
|
||||
from .routes.temporal import router as temporal_router
|
||||
@@ -114,7 +113,6 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
app.include_router(sparql_router)
|
||||
app.include_router(provenance_router)
|
||||
app.include_router(vocabulary_router)
|
||||
app.include_router(ontology_router)
|
||||
|
||||
_WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import CausalChainResponse, CausalDistanceReport, ComplianceResponse, DecisionResponse
|
||||
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/decisions", tags=["Decisions"])
|
||||
@@ -125,20 +125,6 @@ async def get_precedents(
|
||||
return [_node_to_decision(decision) for _, decision in scored[:limit]]
|
||||
|
||||
|
||||
@router.get("/causal-distance", response_model=CausalDistanceReport)
|
||||
async def causal_distance(
|
||||
source: str = Query(..., description="Source node/decision ID"),
|
||||
target: str = Query(..., description="Target node/decision ID"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-8 — Compute causal distance between two decisions via causal-edge-only traversal."""
|
||||
from ...context.causal_analyzer import CausalChainAnalyzer
|
||||
|
||||
analyzer = CausalChainAnalyzer(session.graph)
|
||||
report = await asyncio.to_thread(analyzer.interpret_causal_distance, source, target)
|
||||
return CausalDistanceReport(**report)
|
||||
|
||||
|
||||
@router.get("/{decision_id}/compliance", response_model=ComplianceResponse)
|
||||
async def check_compliance(
|
||||
decision_id: str,
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import DistanceExportRequest, ExportRequest, ImportResponse
|
||||
from ..schemas import ExportRequest, ImportResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -236,58 +236,3 @@ async def export_graph(
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="semantica_export.{extension}"'},
|
||||
)
|
||||
|
||||
|
||||
_DISTANCE_EXPORT_MAX_NODES = 200
|
||||
|
||||
|
||||
@router.post("/api/export/distance-enriched")
|
||||
async def export_distance_enriched(
|
||||
body: DistanceExportRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-10 — Export pairwise distance metrics as CSV or JSONL for ML pipelines."""
|
||||
if not body.node_subset:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"node_subset is required; provide up to {_DISTANCE_EXPORT_MAX_NODES} node IDs to export."
|
||||
),
|
||||
)
|
||||
if len(body.node_subset) > _DISTANCE_EXPORT_MAX_NODES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"node_subset exceeds limit: {len(body.node_subset)} nodes requested; "
|
||||
f"maximum is {_DISTANCE_EXPORT_MAX_NODES}."
|
||||
),
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
from ...export.distance_exporter import DistanceExporter
|
||||
|
||||
exporter = DistanceExporter(session.graph)
|
||||
|
||||
if body.format == "csv":
|
||||
content = await asyncio.to_thread(
|
||||
exporter.to_csv_string,
|
||||
include=body.include,
|
||||
node_subset=body.node_subset,
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": 'attachment; filename="distances.csv"'},
|
||||
)
|
||||
else:
|
||||
content = await asyncio.to_thread(
|
||||
exporter.to_jsonl_string,
|
||||
include=body.include,
|
||||
node_subset=body.node_subset,
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Content-Disposition": 'attachment; filename="distances.jsonl"'},
|
||||
)
|
||||
|
||||
@@ -3,20 +3,14 @@ Graph routes for explorer node, edge, path, and search APIs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ...utils.helpers import classify_path_distance
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import (
|
||||
DistanceMatrixRequest,
|
||||
DistanceMatrixResponse,
|
||||
EdgeListResponse,
|
||||
EdgeResponse,
|
||||
GraphStatsResponse,
|
||||
@@ -27,45 +21,12 @@ from ..schemas import (
|
||||
SearchRequest,
|
||||
SearchResultItem,
|
||||
SearchResultResponse,
|
||||
SemanticNeighborItem,
|
||||
SemanticNeighborhoodResponse,
|
||||
)
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/graph", tags=["Graph"])
|
||||
|
||||
|
||||
def _build_interpretation(
|
||||
distance_band: str,
|
||||
hop_count: int,
|
||||
bottleneck_node: Optional[str],
|
||||
confidence_decay: Optional[float],
|
||||
) -> str:
|
||||
if distance_band == "direct":
|
||||
base = "Direct relationship"
|
||||
elif distance_band == "near":
|
||||
base = f"Closely related via {hop_count - 1} intermediate node(s)"
|
||||
elif distance_band == "mid-range":
|
||||
base = f"Reachable in {hop_count} steps across topic boundaries"
|
||||
else:
|
||||
base = f"Distal connection spanning {hop_count} hops"
|
||||
|
||||
if bottleneck_node:
|
||||
base += f", routed through bottleneck '{bottleneck_node}'"
|
||||
|
||||
if confidence_decay is not None:
|
||||
if confidence_decay > 0.7:
|
||||
base += " — high confidence."
|
||||
elif confidence_decay > 0.4:
|
||||
base += " — moderate confidence."
|
||||
else:
|
||||
base += " — low confidence, treat as weak evidence."
|
||||
else:
|
||||
base += "."
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float, float]]:
|
||||
if not raw_bbox:
|
||||
return None
|
||||
@@ -78,66 +39,6 @@ def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float,
|
||||
return min_x, min_y, max_x, max_y
|
||||
|
||||
|
||||
def _coerce_embedding_vector(value: object) -> Optional[List[float]]:
|
||||
if isinstance(value, dict):
|
||||
# Probe keys in priority order: generic first, then framework-specific.
|
||||
# Must stay aligned with the top-level keys in _extract_node_embeddings.
|
||||
for key in ("embedding", "embeddings", "vector", "values", "node2vec", "semantic"):
|
||||
nested = _coerce_embedding_vector(value.get(key))
|
||||
if nested is not None:
|
||||
return nested
|
||||
return None
|
||||
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return None
|
||||
|
||||
vector: List[float] = []
|
||||
for item in value:
|
||||
try:
|
||||
vector.append(float(item))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return vector if vector else None
|
||||
|
||||
|
||||
def _extract_node_embeddings(graph_dict: dict) -> dict[str, List[float]]:
|
||||
# Top-level keys to probe on each entity (and its metadata/properties dicts).
|
||||
# Priority: generic names first, then KG-extras-specific names.
|
||||
# Must stay aligned with the inner probe list in _coerce_embedding_vector.
|
||||
# TODO: cache this per-session graph revision to avoid re-scanning all nodes on every request.
|
||||
embedding_keys = (
|
||||
"embedding",
|
||||
"embeddings",
|
||||
"vector",
|
||||
"node_embedding",
|
||||
"node2vec_embedding",
|
||||
"semantic_embedding",
|
||||
"reasoning_embedding",
|
||||
)
|
||||
|
||||
embeddings: dict[str, List[float]] = {}
|
||||
for entity in graph_dict.get("entities") or graph_dict.get("nodes") or []:
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
node_id = entity.get("id") or entity.get("node_id")
|
||||
if not node_id:
|
||||
continue
|
||||
|
||||
metadata = entity.get("metadata") if isinstance(entity.get("metadata"), dict) else {}
|
||||
properties = entity.get("properties") if isinstance(entity.get("properties"), dict) else {}
|
||||
|
||||
for key in embedding_keys:
|
||||
vector = _coerce_embedding_vector(
|
||||
entity.get(key, metadata.get(key, properties.get(key)))
|
||||
)
|
||||
if vector is not None:
|
||||
embeddings[str(node_id)] = vector
|
||||
break
|
||||
|
||||
return embeddings
|
||||
|
||||
|
||||
def _node_response(node: dict) -> NodeResponse:
|
||||
return NodeResponse(**node)
|
||||
|
||||
@@ -243,14 +144,14 @@ class _PathAlgorithm(str, Enum):
|
||||
|
||||
|
||||
|
||||
async def _find_path_impl(
|
||||
source: str,
|
||||
target: str,
|
||||
algorithm: _PathAlgorithm,
|
||||
directed: bool,
|
||||
session: GraphSession,
|
||||
) -> PathResponse:
|
||||
"""Resolve and enrich a path between two arbitrary graph node ids."""
|
||||
@router.get("/node/{node_id}/path", response_model=PathResponse)
|
||||
async def find_path(
|
||||
node_id: str,
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
||||
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
path_finder = session.path_finder
|
||||
if path_finder is None:
|
||||
raise HTTPException(status_code=503, detail="PathFinder not available; KG extras may not be installed.")
|
||||
@@ -262,107 +163,20 @@ async def _find_path_impl(
|
||||
else path_finder.bfs_shortest_path
|
||||
)
|
||||
try:
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, source, target, directed=directed)
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target, directed=directed)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{source}' to '{target}': {exc}")
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}': {exc}")
|
||||
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
if not path_nodes:
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{source}' to '{target}'")
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}'")
|
||||
|
||||
total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0
|
||||
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
|
||||
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else 0
|
||||
distance_band = classify_path_distance(hop_count)
|
||||
|
||||
# FR-4 enrichment — compute optional fields from existing session analytics
|
||||
confidence_decay: Optional[float] = None
|
||||
bottleneck_node: Optional[str] = None
|
||||
semantic_similarity: Optional[float] = None
|
||||
path_coherence_score: Optional[float] = None
|
||||
alternative_path_count: int = 0
|
||||
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
|
||||
# Build edge weight index once in O(E) so each hop lookup is O(1).
|
||||
# graph_dict may use "edges" or "relationships" depending on the graph source.
|
||||
edge_weight_index: dict = {}
|
||||
for _e in graph_dict.get("edges") or graph_dict.get("relationships", []):
|
||||
_s, _t = _e.get("source"), _e.get("target")
|
||||
_w = float(_e.get("weight", 1.0))
|
||||
edge_weight_index[(_s, _t)] = _w
|
||||
if not directed:
|
||||
edge_weight_index.setdefault((_t, _s), _w)
|
||||
|
||||
# Confidence decay — product of edge weights along the path (O(L))
|
||||
decay = 1.0
|
||||
for i in range(len(path_nodes) - 1):
|
||||
decay *= edge_weight_index.get((path_nodes[i], path_nodes[i + 1]), 1.0)
|
||||
confidence_decay = decay
|
||||
|
||||
# Bottleneck — intermediate node with highest betweenness in subgraph
|
||||
intermediates = path_nodes[1:-1] if len(path_nodes) > 2 else []
|
||||
if intermediates and session.centrality is not None:
|
||||
sub_dict = await asyncio.to_thread(session.build_graph_dict, path_nodes)
|
||||
centrality_result = await asyncio.to_thread(
|
||||
session.centrality.calculate_betweenness_centrality, sub_dict
|
||||
)
|
||||
scores = centrality_result.get("betweenness", {}) if isinstance(centrality_result, dict) else {}
|
||||
if scores:
|
||||
bottleneck_node = max(
|
||||
(n for n in intermediates if n in scores),
|
||||
key=lambda n: scores.get(n, 0.0),
|
||||
default=None,
|
||||
)
|
||||
|
||||
# Alternative paths — count simple paths within hop_count + 2
|
||||
if path_finder is not None and hop_count > 0:
|
||||
try:
|
||||
k_paths = await asyncio.to_thread(
|
||||
path_finder.find_k_shortest_paths,
|
||||
graph_dict, source, target, hop_count + 2, directed=directed
|
||||
)
|
||||
alternative_path_count = max(0, len(k_paths) - 1)
|
||||
except Exception as exc:
|
||||
logger.debug("k_shortest_paths unavailable for enrichment: %s", exc)
|
||||
|
||||
# Semantic similarity (source ↔ target)
|
||||
if session.similarity is not None:
|
||||
try:
|
||||
sim_result = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity,
|
||||
graph_dict, source, target
|
||||
)
|
||||
if isinstance(sim_result, (int, float)):
|
||||
semantic_similarity = float(sim_result)
|
||||
except Exception as exc:
|
||||
logger.debug("semantic_similarity unavailable for enrichment: %s", exc)
|
||||
|
||||
# Path coherence — mean pairwise similarity of consecutive nodes
|
||||
if session.similarity is not None and len(path_nodes) >= 2:
|
||||
try:
|
||||
pair_sims: List[float] = []
|
||||
for i in range(len(path_nodes) - 1):
|
||||
sim = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity,
|
||||
graph_dict, path_nodes[i], path_nodes[i + 1]
|
||||
)
|
||||
if isinstance(sim, (int, float)):
|
||||
pair_sims.append(float(sim))
|
||||
if pair_sims:
|
||||
path_coherence_score = sum(pair_sims) / len(pair_sims)
|
||||
except Exception as exc:
|
||||
logger.debug("path_coherence unavailable for enrichment: %s", exc)
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("FR-4 enrichment skipped: %s", exc)
|
||||
|
||||
interpretation = _build_interpretation(distance_band, hop_count, bottleneck_node, confidence_decay)
|
||||
|
||||
return PathResponse(
|
||||
source=source,
|
||||
source=node_id,
|
||||
target=target,
|
||||
algorithm=algorithm.value,
|
||||
path=path_nodes,
|
||||
@@ -370,268 +184,23 @@ async def _find_path_impl(
|
||||
total_weight=total_weight,
|
||||
directed=directed,
|
||||
hop_count=hop_count,
|
||||
distance_band=distance_band,
|
||||
semantic_similarity=semantic_similarity,
|
||||
path_coherence_score=path_coherence_score,
|
||||
confidence_decay=confidence_decay,
|
||||
bottleneck_node=bottleneck_node,
|
||||
alternative_path_count=alternative_path_count,
|
||||
interpretation=interpretation,
|
||||
distance_band=classify_path_distance(hop_count),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/path", response_model=PathResponse)
|
||||
async def find_path_by_query(
|
||||
source: str = Query(..., description="Source node ID"),
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
||||
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
return await _find_path_impl(source, target, algorithm, directed, session)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/path", response_model=PathResponse)
|
||||
async def find_path(
|
||||
node_id: str,
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
||||
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Deprecated path-segment route kept for backward compatibility.
|
||||
|
||||
Node IDs that contain slashes will return 404 because FastAPI decodes
|
||||
%2F before route matching. Use GET /api/graph/path?source=...&target=...
|
||||
for slash-safe path lookup.
|
||||
"""
|
||||
return await _find_path_impl(node_id, target, algorithm, directed, session)
|
||||
|
||||
|
||||
@router.post("/search", response_model=SearchResultResponse)
|
||||
async def search_nodes(
|
||||
body: SearchRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
results = await asyncio.to_thread(session.search, body.query, body.limit, body.filters)
|
||||
|
||||
# FR-7 — compute hop distances from anchor when requested
|
||||
hop_by_id: dict = {}
|
||||
if body.anchor_node:
|
||||
neighbors = await asyncio.to_thread(
|
||||
session.graph.get_neighbor_distances,
|
||||
body.anchor_node,
|
||||
hops=body.max_hops if body.max_hops is not None else 10,
|
||||
)
|
||||
hop_by_id = {n.get("id"): n.get("hop") for n in neighbors}
|
||||
hop_by_id[body.anchor_node] = 0
|
||||
|
||||
items: List[SearchResultItem] = []
|
||||
for result in results:
|
||||
node_data = result.get("node", {})
|
||||
node_id = node_data.get("id", "")
|
||||
raw_score = result.get("score", 0.0)
|
||||
|
||||
hop_distance: Optional[int] = hop_by_id.get(node_id) if body.anchor_node else None
|
||||
|
||||
# Drop results beyond max_hops
|
||||
if body.anchor_node and body.max_hops is not None:
|
||||
if hop_distance is None or hop_distance > body.max_hops:
|
||||
continue
|
||||
|
||||
# Compute combined ranking score
|
||||
final_score = raw_score
|
||||
if body.anchor_node and hop_distance is not None:
|
||||
proximity = 1.0 if hop_distance == 0 else 1.0 / hop_distance
|
||||
if body.rank_by == "proximity":
|
||||
final_score = proximity
|
||||
elif body.rank_by == "hybrid":
|
||||
final_score = 0.6 * raw_score + 0.4 * proximity
|
||||
|
||||
items.append(
|
||||
SearchResultItem(
|
||||
node=_node_response(node_data),
|
||||
score=final_score,
|
||||
hop_distance=hop_distance,
|
||||
)
|
||||
)
|
||||
|
||||
if body.rank_by in ("proximity", "hybrid") and body.anchor_node:
|
||||
items.sort(key=lambda item: item.score, reverse=True)
|
||||
|
||||
items = [
|
||||
SearchResultItem(node=_node_response(result.get("node", {})), score=result.get("score", 0.0))
|
||||
for result in results
|
||||
]
|
||||
return SearchResultResponse(results=items, total=len(items), query=body.query)
|
||||
|
||||
|
||||
@router.post("/distance-matrix", response_model=DistanceMatrixResponse)
|
||||
async def distance_matrix(
|
||||
body: DistanceMatrixRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
if len(body.node_ids) > 50:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Too many nodes: {len(body.node_ids)} requested; maximum is 50 per request.",
|
||||
)
|
||||
|
||||
if body.metric == "semantic" and session.similarity is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="metric='semantic' requires an embedding backend which is not available in this session.",
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
path_finder = session.path_finder
|
||||
|
||||
n = len(body.node_ids)
|
||||
matrix: List[List[Optional[float]]] = [[None] * n for _ in range(n)]
|
||||
unreachable: List[tuple] = []
|
||||
|
||||
for i in range(n):
|
||||
matrix[i][i] = 0.0
|
||||
for j in range(i + 1, n):
|
||||
src, tgt = body.node_ids[i], body.node_ids[j]
|
||||
try:
|
||||
if body.metric == "semantic" and session.similarity is not None:
|
||||
sim = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity, graph_dict, src, tgt
|
||||
)
|
||||
val = 1.0 - float(sim) if isinstance(sim, (int, float)) else None
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
elif path_finder is not None:
|
||||
path_fn = (
|
||||
path_finder.dijkstra_shortest_path
|
||||
if body.metric == "weighted"
|
||||
else path_finder.bfs_shortest_path
|
||||
)
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, src, tgt)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
if path_nodes:
|
||||
val = (
|
||||
float(result.get("total_weight", len(path_nodes) - 1))
|
||||
if body.metric == "weighted"
|
||||
else float(len(path_nodes) - 1)
|
||||
)
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
else:
|
||||
unreachable.append((src, tgt))
|
||||
unreachable.append((tgt, src))
|
||||
except Exception as exc:
|
||||
logger.debug("distance_matrix pair (%s, %s) failed: %s", src, tgt, exc)
|
||||
unreachable.append((src, tgt))
|
||||
unreachable.append((tgt, src))
|
||||
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
return DistanceMatrixResponse(
|
||||
nodes=body.node_ids,
|
||||
metric=body.metric,
|
||||
matrix=matrix,
|
||||
unreachable_pairs=unreachable,
|
||||
computation_time_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
|
||||
async def _semantic_neighborhood_impl(
|
||||
node_id: str,
|
||||
top_k: int,
|
||||
min_similarity: float,
|
||||
session: GraphSession,
|
||||
) -> SemanticNeighborhoodResponse:
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
|
||||
similarity = session.similarity
|
||||
if similarity is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Semantic similarity is unavailable for this graph session.",
|
||||
)
|
||||
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
embeddings = _extract_node_embeddings(graph_dict)
|
||||
query_embedding = embeddings.get(node_id)
|
||||
if not embeddings or query_embedding is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Semantic similarity is unavailable because this graph has no node embeddings.",
|
||||
)
|
||||
|
||||
neighbors: List[SemanticNeighborItem] = []
|
||||
try:
|
||||
similar = await asyncio.to_thread(
|
||||
similarity.find_most_similar,
|
||||
embeddings,
|
||||
query_embedding,
|
||||
top_k=top_k * 2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("semantic_neighborhood similarity search failed: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Semantic similarity search failed for this graph session.",
|
||||
) from exc
|
||||
|
||||
# find_most_similar returns list of (node_id, score) or dicts
|
||||
for item in similar:
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
nid, sim_score = item[0], item[1]
|
||||
elif isinstance(item, dict):
|
||||
nid = item.get("node_id") or item.get("id", "")
|
||||
sim_score = item.get("similarity", item.get("score", 0.0))
|
||||
else:
|
||||
continue
|
||||
if float(sim_score) < min_similarity or nid == node_id:
|
||||
continue
|
||||
neighbor_node = await asyncio.to_thread(session.get_node, nid)
|
||||
if neighbor_node is None:
|
||||
continue
|
||||
neighbors.append(
|
||||
SemanticNeighborItem(
|
||||
id=str(nid),
|
||||
type=neighbor_node.get("type", ""),
|
||||
content=neighbor_node.get("content", ""),
|
||||
similarity=float(sim_score),
|
||||
)
|
||||
)
|
||||
if len(neighbors) >= top_k:
|
||||
break
|
||||
|
||||
return SemanticNeighborhoodResponse(
|
||||
anchor_node=node_id,
|
||||
neighbors=neighbors,
|
||||
total=len(neighbors),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
|
||||
async def semantic_neighborhood_by_query(
|
||||
node_id: str = Query(..., description="Anchor node ID"),
|
||||
top_k: int = Query(20, ge=1, le=200),
|
||||
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
return await _semantic_neighborhood_impl(node_id, top_k, min_similarity, session)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
|
||||
async def semantic_neighborhood(
|
||||
node_id: str,
|
||||
top_k: int = Query(20, ge=1, le=200),
|
||||
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Deprecated path-segment route kept for backward compatibility.
|
||||
|
||||
Node IDs that contain slashes will return 404 because FastAPI decodes
|
||||
%2F before route matching. Use GET /api/graph/semantic-neighborhood?node_id=...
|
||||
for slash-safe semantic neighborhood lookup.
|
||||
"""
|
||||
return await _semantic_neighborhood_impl(node_id, top_k, min_similarity, session)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=GraphStatsResponse)
|
||||
async def graph_stats(
|
||||
session: GraphSession = Depends(get_session),
|
||||
|
||||
@@ -1,894 +0,0 @@
|
||||
"""
|
||||
Ontology Hub routes: registry, URL/file loading, preview, creation, entity search, and SKOS.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..session import GraphSession
|
||||
from ..utils.rdf_parser import _safe_parse_rdf
|
||||
|
||||
router = APIRouter(prefix="/api/ontology", tags=["Ontology"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_FETCH_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||
|
||||
_CLASS_TYPES = frozenset({
|
||||
"owl:Class", "rdfs:Class",
|
||||
"http://www.w3.org/2002/07/owl#Class",
|
||||
"http://www.w3.org/2000/01/rdf-schema#Class",
|
||||
})
|
||||
_PROPERTY_TYPES = frozenset({
|
||||
"owl:ObjectProperty", "owl:DatatypeProperty", "owl:AnnotationProperty",
|
||||
"rdfs:Property",
|
||||
"http://www.w3.org/2002/07/owl#ObjectProperty",
|
||||
"http://www.w3.org/2002/07/owl#DatatypeProperty",
|
||||
"http://www.w3.org/2002/07/owl#AnnotationProperty",
|
||||
})
|
||||
_INDIVIDUAL_TYPES = frozenset({
|
||||
"owl:NamedIndividual",
|
||||
"http://www.w3.org/2002/07/owl#NamedIndividual",
|
||||
})
|
||||
_CONCEPT_TYPES = frozenset({
|
||||
"skos:Concept",
|
||||
"http://www.w3.org/2004/02/skos/core#Concept",
|
||||
})
|
||||
_SCHEME_TYPES = frozenset({
|
||||
"skos:ConceptScheme",
|
||||
"http://www.w3.org/2004/02/skos/core#ConceptScheme",
|
||||
})
|
||||
_ONTOLOGY_TYPES = frozenset({
|
||||
"owl:Ontology",
|
||||
"http://www.w3.org/2002/07/owl#Ontology",
|
||||
}) | _SCHEME_TYPES
|
||||
|
||||
_SEARCHABLE_TYPES = _CLASS_TYPES | _PROPERTY_TYPES | _INDIVIDUAL_TYPES | _CONCEPT_TYPES | _SCHEME_TYPES
|
||||
|
||||
_URI_PREFIX_MAP = {
|
||||
"http://www.w3.org/2002/07/owl#": "owl:",
|
||||
"http://www.w3.org/2000/01/rdf-schema#": "rdfs:",
|
||||
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf:",
|
||||
"http://www.w3.org/2004/02/skos/core#": "skos:",
|
||||
"http://purl.org/dc/terms/": "dcterms:",
|
||||
"http://purl.org/dc/elements/1.1/": "dc:",
|
||||
"http://schema.org/": "schema:",
|
||||
"http://www.w3.org/ns/shacl#": "sh:",
|
||||
}
|
||||
|
||||
_FORMAT_ALIASES: Dict[str, str] = {
|
||||
"ttl": "turtle",
|
||||
"rdf": "xml",
|
||||
"owl": "xml",
|
||||
"jsonld": "json-ld",
|
||||
"json": "json-ld",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OntologyEntry(BaseModel):
|
||||
uri: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
format: str = "unknown"
|
||||
status: Literal["published", "draft", "external"] = "external"
|
||||
source_url: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
class_count: int = 0
|
||||
concept_count: int = 0
|
||||
property_count: int = 0
|
||||
loaded_at: str = ""
|
||||
enabled: bool = True
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OntologyPreview(BaseModel):
|
||||
uri: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
namespace: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
format: str
|
||||
estimated_triples: int = 0
|
||||
source_url: Optional[str] = None
|
||||
|
||||
|
||||
class LoadOntologyRequest(BaseModel):
|
||||
url: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PreviewOntologyRequest(BaseModel):
|
||||
url: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
|
||||
|
||||
class CreateOntologyRequest(BaseModel):
|
||||
mode: Literal["scratch", "data", "text"] = "scratch"
|
||||
namespace: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
sample_data: Optional[str] = None
|
||||
schema_text: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
|
||||
|
||||
class OntologySearchResult(BaseModel):
|
||||
uri: str
|
||||
label: str
|
||||
type: str
|
||||
entity_type: str
|
||||
definition: Optional[str] = None
|
||||
source_ontology: Optional[str] = None
|
||||
namespace_prefix: Optional[str] = None
|
||||
|
||||
|
||||
class EntityDetailResponse(BaseModel):
|
||||
uri: str
|
||||
label: str
|
||||
type: str
|
||||
entity_type: str
|
||||
definition: Optional[str] = None
|
||||
source_ontology: Optional[str] = None
|
||||
superclasses: List[str] = Field(default_factory=list)
|
||||
subclasses: List[str] = Field(default_factory=list)
|
||||
domain: List[str] = Field(default_factory=list)
|
||||
range: List[str] = Field(default_factory=list)
|
||||
instance_count: int = 0
|
||||
properties: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SKOSScheme(BaseModel):
|
||||
uri: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
concept_count: int = 0
|
||||
|
||||
|
||||
class SKOSConceptDetail(BaseModel):
|
||||
uri: str
|
||||
pref_label: str
|
||||
alt_labels: List[str] = Field(default_factory=list)
|
||||
hidden_labels: List[str] = Field(default_factory=list)
|
||||
definition: Optional[str] = None
|
||||
scope_note: Optional[str] = None
|
||||
editorial_note: Optional[str] = None
|
||||
broader: List[str] = Field(default_factory=list)
|
||||
narrower: List[str] = Field(default_factory=list)
|
||||
related: List[str] = Field(default_factory=list)
|
||||
exact_match: List[str] = Field(default_factory=list)
|
||||
close_match: List[str] = Field(default_factory=list)
|
||||
broad_match: List[str] = Field(default_factory=list)
|
||||
narrow_match: List[str] = Field(default_factory=list)
|
||||
scheme_uri: Optional[str] = None
|
||||
|
||||
|
||||
class LoadOntologyResponse(BaseModel):
|
||||
status: str = "success"
|
||||
uri: str
|
||||
name: str
|
||||
nodes_added: int = 0
|
||||
edges_added: int = 0
|
||||
format: str = "unknown"
|
||||
|
||||
|
||||
class ToggleResponse(BaseModel):
|
||||
uri: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
status: str = "success"
|
||||
uri: str
|
||||
nodes_added: int = 0
|
||||
edges_added: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_registry(request: Request) -> Dict[str, OntologyEntry]:
|
||||
if not hasattr(request.app.state, "ontology_registry"):
|
||||
request.app.state.ontology_registry = {}
|
||||
return request.app.state.ontology_registry
|
||||
|
||||
|
||||
def _uri_to_prefix(uri: str) -> str:
|
||||
for base, prefix in _URI_PREFIX_MAP.items():
|
||||
if uri.startswith(base):
|
||||
return prefix + uri[len(base):]
|
||||
return uri
|
||||
|
||||
|
||||
def _classify_node_type(node_type: str) -> str:
|
||||
if node_type in _CLASS_TYPES:
|
||||
return "class"
|
||||
if node_type in _PROPERTY_TYPES:
|
||||
return "property"
|
||||
if node_type in _INDIVIDUAL_TYPES:
|
||||
return "individual"
|
||||
if node_type in _CONCEPT_TYPES:
|
||||
return "concept"
|
||||
if node_type in _SCHEME_TYPES:
|
||||
return "scheme"
|
||||
if node_type in _ONTOLOGY_TYPES:
|
||||
return "ontology"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _node_label(node: Dict[str, Any]) -> str:
|
||||
props = node.get("properties", {})
|
||||
return (
|
||||
props.get("pref_label")
|
||||
or props.get("rdfs:label")
|
||||
or props.get("skos:prefLabel")
|
||||
or props.get("label")
|
||||
or props.get("content")
|
||||
or node.get("content", "")
|
||||
or node.get("id", "")
|
||||
)
|
||||
|
||||
|
||||
def _extract_namespace(uri: str) -> Optional[str]:
|
||||
if "#" in uri:
|
||||
return uri.rsplit("#", 1)[0] + "#"
|
||||
if "/" in uri:
|
||||
return uri.rsplit("/", 1)[0] + "/"
|
||||
return None
|
||||
|
||||
|
||||
def _detect_format(content: str) -> str:
|
||||
stripped = content.strip()[:500]
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
return "json-ld"
|
||||
if stripped.startswith("<"):
|
||||
return "xml"
|
||||
if "@prefix" in stripped or "@base" in stripped:
|
||||
return "turtle"
|
||||
# N-Triples blank-node subject: "_:word <predicate-uri> ..."
|
||||
# URI-subject N-Triples ("<uri> <uri>") are already caught by the XML
|
||||
# branch above, so only the blank-node form needs to be checked here.
|
||||
# Plain string ops avoid the polynomial regex that CodeQL flags (py/polynomial-redos).
|
||||
if stripped.startswith("_:") and " <" in stripped:
|
||||
return "nt"
|
||||
return "turtle"
|
||||
|
||||
|
||||
def _normalize_format(fmt: Optional[str]) -> str:
|
||||
if not fmt:
|
||||
return "turtle"
|
||||
lower = fmt.strip().lower()
|
||||
return _FORMAT_ALIASES.get(lower, lower)
|
||||
|
||||
|
||||
def _validate_fetch_url(url: str) -> None:
|
||||
"""Reject non-HTTP(S) schemes and private/loopback/link-local targets."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=422, detail="Only http and https URLs are allowed.")
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise HTTPException(status_code=422, detail="Invalid URL: missing hostname.")
|
||||
try:
|
||||
addrinfos = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}': {exc}") from exc
|
||||
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
|
||||
try:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved or ip.is_multicast:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Fetching from private, loopback, or reserved network addresses is not allowed.",
|
||||
)
|
||||
|
||||
|
||||
def _fetch_url_sync(url: str) -> bytes:
|
||||
_validate_fetch_url(url)
|
||||
import requests as _req
|
||||
try:
|
||||
resp = _req.get(
|
||||
url,
|
||||
headers={"Accept": "text/turtle, application/rdf+xml, application/ld+json, */*;q=0.1"},
|
||||
timeout=30,
|
||||
stream=True,
|
||||
allow_redirects=True,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
chunks: List[bytes] = []
|
||||
total = 0
|
||||
for chunk in resp.iter_content(65536):
|
||||
total += len(chunk)
|
||||
if total > _MAX_FETCH_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Could not fetch {url}: {exc}") from exc
|
||||
|
||||
|
||||
def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
|
||||
"""Return (nodes, edges, metadata). Raises HTTPException on failure."""
|
||||
try:
|
||||
import rdflib
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="rdflib is not installed.")
|
||||
|
||||
fmt_map = {
|
||||
"turtle": "turtle", "xml": "xml", "nt": "nt",
|
||||
"json-ld": "json-ld", "n3": "n3",
|
||||
}
|
||||
parse_fmt = fmt_map.get(fmt, "turtle")
|
||||
|
||||
g = rdflib.Graph()
|
||||
try:
|
||||
_safe_parse_rdf(g, content, parse_fmt)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=422, detail=f"RDF parse error: {exc}") from exc
|
||||
|
||||
OWL = rdflib.Namespace("http://www.w3.org/2002/07/owl#")
|
||||
RDF = rdflib.RDF
|
||||
RDFS = rdflib.RDFS
|
||||
SKOS = rdflib.Namespace("http://www.w3.org/2004/02/skos/core#")
|
||||
DCT = rdflib.Namespace("http://purl.org/dc/terms/")
|
||||
DC = rdflib.Namespace("http://purl.org/dc/elements/1.1/")
|
||||
|
||||
metadata: Dict[str, Any] = {}
|
||||
|
||||
for subj in g.subjects(RDF.type, OWL.Ontology):
|
||||
metadata["uri"] = str(subj)
|
||||
for pred, obj in g.predicate_objects(subj):
|
||||
p = str(pred)
|
||||
if p in {str(RDFS.label), str(DCT.title), str(DC.title)}:
|
||||
metadata.setdefault("name", str(obj))
|
||||
elif p in {str(RDFS.comment), str(DCT.description), str(DC.description)}:
|
||||
metadata.setdefault("description", str(obj))
|
||||
elif p == str(OWL.versionInfo):
|
||||
metadata.setdefault("version", str(obj))
|
||||
elif p in {str(DCT.license), str(DC.rights)}:
|
||||
metadata.setdefault("license", str(obj))
|
||||
break
|
||||
|
||||
if "uri" not in metadata:
|
||||
for subj in g.subjects(RDF.type, SKOS.ConceptScheme):
|
||||
metadata["uri"] = str(subj)
|
||||
for pred, obj in g.predicate_objects(subj):
|
||||
p = str(pred)
|
||||
if p in {str(SKOS.prefLabel), str(DCT.title), str(DC.title)}:
|
||||
metadata.setdefault("name", str(obj))
|
||||
elif p in {str(SKOS.definition), str(DCT.description)}:
|
||||
metadata.setdefault("description", str(obj))
|
||||
break
|
||||
|
||||
if "uri" not in metadata:
|
||||
metadata["uri"] = f"urn:semantica:onto:{uuid.uuid4().hex[:8]}"
|
||||
metadata.setdefault("name", metadata["uri"].rsplit("/", 1)[-1].rsplit("#", 1)[-1] or "Unnamed")
|
||||
metadata["triple_count"] = len(g)
|
||||
|
||||
# Collect literal properties per subject
|
||||
literal_props: Dict[str, Dict[str, str]] = {}
|
||||
for subj, pred, obj in g:
|
||||
if isinstance(subj, rdflib.BNode) or not isinstance(obj, rdflib.Literal):
|
||||
continue
|
||||
sid = str(subj)
|
||||
pk = _uri_to_prefix(str(pred))
|
||||
literal_props.setdefault(sid, {})[pk] = str(obj)
|
||||
|
||||
# Build nodes from rdf:type statements
|
||||
seen_ids: set = set()
|
||||
nodes: List[Dict[str, Any]] = []
|
||||
for subj, _, type_obj in g.triples((None, RDF.type, None)):
|
||||
if isinstance(subj, rdflib.BNode):
|
||||
continue
|
||||
sid = str(subj)
|
||||
ntype = _uri_to_prefix(str(type_obj))
|
||||
if sid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(sid)
|
||||
props = dict(literal_props.get(sid, {}))
|
||||
props["uri"] = sid
|
||||
label = (
|
||||
props.get("rdfs:label")
|
||||
or props.get("skos:prefLabel")
|
||||
or props.get("dcterms:title")
|
||||
or sid.rsplit("/", 1)[-1].rsplit("#", 1)[-1]
|
||||
)
|
||||
nodes.append({"id": sid, "type": ntype, "content": label, "properties": props})
|
||||
|
||||
# Build edges from non-literal object statements
|
||||
edges: List[Dict[str, Any]] = []
|
||||
for subj, pred, obj in g:
|
||||
if isinstance(subj, rdflib.BNode) or isinstance(obj, (rdflib.Literal, rdflib.BNode)):
|
||||
continue
|
||||
edges.append({
|
||||
"source": str(subj),
|
||||
"target": str(obj),
|
||||
"type": _uri_to_prefix(str(pred)),
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
return nodes, edges, metadata
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry endpoints (all specific paths before wildcard)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/registry", response_model=List[OntologyEntry])
|
||||
async def list_registry(
|
||||
request: Request,
|
||||
q: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
format: Optional[str] = Query(None),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
registry = _get_registry(request)
|
||||
|
||||
# Discover ontology-type nodes from live graph not yet registered
|
||||
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
|
||||
# Count entity types per ontology URI via scheme_uri property
|
||||
class_counts: Dict[str, int] = {}
|
||||
concept_counts: Dict[str, int] = {}
|
||||
prop_counts: Dict[str, int] = {}
|
||||
implicit: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for node in all_nodes:
|
||||
ntype = node.get("type", "")
|
||||
nid = node.get("id", "")
|
||||
etype = _classify_node_type(ntype)
|
||||
scheme_uri = node.get("properties", {}).get("scheme_uri") or node.get("properties", {}).get("uri")
|
||||
|
||||
if etype == "ontology" or etype == "scheme":
|
||||
if nid and nid not in registry:
|
||||
implicit[nid] = node
|
||||
elif scheme_uri:
|
||||
if etype == "class":
|
||||
class_counts[scheme_uri] = class_counts.get(scheme_uri, 0) + 1
|
||||
elif etype == "concept":
|
||||
concept_counts[scheme_uri] = concept_counts.get(scheme_uri, 0) + 1
|
||||
elif etype == "property":
|
||||
prop_counts[scheme_uri] = prop_counts.get(scheme_uri, 0) + 1
|
||||
|
||||
result: List[OntologyEntry] = []
|
||||
|
||||
def _matches(name: str, uri: str, desc: str) -> bool:
|
||||
if not q:
|
||||
return True
|
||||
ql = q.lower()
|
||||
return any(ql in t.lower() for t in [name, uri, desc] if t)
|
||||
|
||||
for entry in registry.values():
|
||||
if status and entry.status != status:
|
||||
continue
|
||||
if format and entry.format.lower() != format.lower():
|
||||
continue
|
||||
if not _matches(entry.name, entry.uri, entry.description or ""):
|
||||
continue
|
||||
updated = entry.model_copy(update={
|
||||
"class_count": class_counts.get(entry.uri, entry.class_count),
|
||||
"concept_count": concept_counts.get(entry.uri, entry.concept_count),
|
||||
"property_count": prop_counts.get(entry.uri, entry.property_count),
|
||||
})
|
||||
result.append(updated)
|
||||
|
||||
for nid, node in implicit.items():
|
||||
props = node.get("properties", {})
|
||||
name = _node_label(node) or nid
|
||||
if not _matches(name, nid, props.get("description", "")):
|
||||
continue
|
||||
result.append(OntologyEntry(
|
||||
uri=nid,
|
||||
name=name,
|
||||
description=props.get("description"),
|
||||
format=props.get("format", "unknown"),
|
||||
status="external",
|
||||
version=props.get("version") or props.get("owl:versionInfo"),
|
||||
class_count=class_counts.get(nid, 0),
|
||||
concept_count=concept_counts.get(nid, 0),
|
||||
property_count=prop_counts.get(nid, 0),
|
||||
loaded_at=props.get("loaded_at", ""),
|
||||
enabled=True,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/preview", response_model=OntologyPreview)
|
||||
async def preview_ontology(body: PreviewOntologyRequest):
|
||||
if not body.url and not body.content:
|
||||
raise HTTPException(status_code=422, detail="Provide either url or content.")
|
||||
|
||||
if body.url:
|
||||
raw = await asyncio.to_thread(_fetch_url_sync, body.url)
|
||||
content_str = raw.decode("utf-8", errors="replace")
|
||||
else:
|
||||
content_str = body.content or ""
|
||||
|
||||
fmt = _normalize_format(body.format) if body.format else _detect_format(content_str)
|
||||
|
||||
try:
|
||||
_, _, metadata = await asyncio.to_thread(
|
||||
_parse_rdf_sync, content_str.encode("utf-8"), fmt
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Could not parse ontology: {exc}") from exc
|
||||
|
||||
return OntologyPreview(
|
||||
uri=metadata.get("uri", ""),
|
||||
name=metadata.get("name", ""),
|
||||
description=metadata.get("description"),
|
||||
namespace=_extract_namespace(metadata.get("uri", "")),
|
||||
version=metadata.get("version"),
|
||||
license=metadata.get("license"),
|
||||
format=fmt,
|
||||
estimated_triples=metadata.get("triple_count", 0),
|
||||
source_url=body.url,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/load", response_model=LoadOntologyResponse)
|
||||
async def load_ontology(
|
||||
request: Request,
|
||||
body: LoadOntologyRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
if not body.url and not body.content:
|
||||
raise HTTPException(status_code=422, detail="Provide either url or content.")
|
||||
|
||||
if body.url:
|
||||
raw = await asyncio.to_thread(_fetch_url_sync, body.url)
|
||||
content_str = raw.decode("utf-8", errors="replace")
|
||||
else:
|
||||
content_str = body.content or ""
|
||||
|
||||
fmt = _normalize_format(body.format) if body.format else _detect_format(content_str)
|
||||
|
||||
try:
|
||||
nodes, edges, metadata = await asyncio.to_thread(
|
||||
_parse_rdf_sync, content_str.encode("utf-8"), fmt
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Could not parse ontology: {exc}") from exc
|
||||
|
||||
onto_uri = metadata.get("uri", f"urn:semantica:onto:{uuid.uuid4().hex[:8]}")
|
||||
onto_name = body.name or metadata.get("name", "Unnamed Ontology")
|
||||
|
||||
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
|
||||
edges_added = await asyncio.to_thread(session.add_edges, edges)
|
||||
|
||||
registry = _get_registry(request)
|
||||
registry[onto_uri] = OntologyEntry(
|
||||
uri=onto_uri,
|
||||
name=onto_name,
|
||||
description=body.description or metadata.get("description"),
|
||||
format=fmt,
|
||||
status="external",
|
||||
source_url=body.url,
|
||||
version=metadata.get("version"),
|
||||
class_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) == "class"),
|
||||
concept_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) in ("concept", "scheme")),
|
||||
property_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) == "property"),
|
||||
loaded_at=datetime.now(UTC).isoformat(),
|
||||
enabled=True,
|
||||
tags=body.tags,
|
||||
)
|
||||
|
||||
return LoadOntologyResponse(
|
||||
uri=onto_uri, name=onto_name,
|
||||
nodes_added=nodes_added, edges_added=edges_added, format=fmt,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/create", response_model=LoadOntologyResponse)
|
||||
async def create_ontology(
|
||||
request: Request,
|
||||
body: CreateOntologyRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
ns = body.namespace.rstrip("/#")
|
||||
onto_uri = f"{ns}#ontology"
|
||||
nodes: List[Dict[str, Any]] = [{
|
||||
"id": onto_uri,
|
||||
"type": "owl:Ontology",
|
||||
"content": body.name,
|
||||
"properties": {
|
||||
"rdfs:label": body.name,
|
||||
"rdfs:comment": body.description or "",
|
||||
"namespace": body.namespace,
|
||||
},
|
||||
}]
|
||||
edges: List[Dict[str, Any]] = []
|
||||
|
||||
if body.mode == "data" and body.sample_data:
|
||||
try:
|
||||
from ...ontology import OntologyEngine
|
||||
engine = OntologyEngine()
|
||||
result = await asyncio.to_thread(engine.from_data, body.sample_data)
|
||||
for cls in (result.get("classes", []) if isinstance(result, dict) else []):
|
||||
cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
|
||||
nodes.append({
|
||||
"id": cls_uri, "type": "owl:Class",
|
||||
"content": cls.get("name", ""),
|
||||
"properties": {"rdfs:label": cls.get("name", "")},
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("Failed to generate ontology from sample data; falling back to minimal ontology.")
|
||||
|
||||
elif body.mode == "text" and body.schema_text:
|
||||
try:
|
||||
from ...ontology import OntologyEngine
|
||||
engine = OntologyEngine()
|
||||
result = await asyncio.to_thread(engine.from_text, body.schema_text)
|
||||
for cls in (result.get("classes", []) if isinstance(result, dict) else []):
|
||||
cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
|
||||
nodes.append({
|
||||
"id": cls_uri, "type": "owl:Class",
|
||||
"content": cls.get("name", ""),
|
||||
"properties": {"rdfs:label": cls.get("name", "")},
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("Failed to generate ontology from schema text; falling back to minimal ontology.")
|
||||
|
||||
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
|
||||
edges_added = await asyncio.to_thread(session.add_edges, edges)
|
||||
|
||||
registry = _get_registry(request)
|
||||
registry[onto_uri] = OntologyEntry(
|
||||
uri=onto_uri,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
format="turtle",
|
||||
status="draft",
|
||||
version="0.1.0",
|
||||
class_count=sum(1 for n in nodes if n.get("type") == "owl:Class"),
|
||||
loaded_at=datetime.now(UTC).isoformat(),
|
||||
enabled=True,
|
||||
tags=body.tags,
|
||||
)
|
||||
|
||||
return LoadOntologyResponse(
|
||||
uri=onto_uri, name=body.name,
|
||||
nodes_added=nodes_added, edges_added=edges_added, format="turtle",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/search", response_model=List[OntologySearchResult])
|
||||
async def search_entities(
|
||||
q: str = Query(..., min_length=1),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
# Use the session's indexed search; over-fetch to allow post-filtering by entity type
|
||||
raw_hits = await asyncio.to_thread(session.search, q, limit * 6)
|
||||
results: List[OntologySearchResult] = []
|
||||
|
||||
for hit in raw_hits:
|
||||
node = hit.get("node", hit) # session.search returns {"node": ..., "score": ...}
|
||||
ntype = node.get("type", "")
|
||||
if ntype not in _SEARCHABLE_TYPES:
|
||||
continue
|
||||
etype = _classify_node_type(ntype)
|
||||
if entity_type and etype != entity_type:
|
||||
continue
|
||||
|
||||
label = _node_label(node)
|
||||
props = node.get("properties", {})
|
||||
definition = (
|
||||
props.get("rdfs:comment")
|
||||
or props.get("skos:definition")
|
||||
or props.get("description")
|
||||
)
|
||||
|
||||
results.append(OntologySearchResult(
|
||||
uri=node.get("id", ""),
|
||||
label=label,
|
||||
type=ntype,
|
||||
entity_type=etype,
|
||||
definition=definition,
|
||||
source_ontology=props.get("scheme_uri"),
|
||||
namespace_prefix=_extract_namespace(node.get("id", "")),
|
||||
))
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
|
||||
async def get_entity_detail(
|
||||
entity_uri: str,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, entity_uri)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found.")
|
||||
|
||||
props = node.get("properties", {})
|
||||
ntype = node.get("type", "")
|
||||
label = _node_label(node)
|
||||
definition = props.get("rdfs:comment") or props.get("skos:definition") or props.get("description")
|
||||
|
||||
out_edges, _ = await asyncio.to_thread(session.get_edges, source=entity_uri, skip=0, limit=9999)
|
||||
in_edges, _ = await asyncio.to_thread(session.get_edges, target=entity_uri, skip=0, limit=9999)
|
||||
|
||||
superclasses = [e["target"] for e in out_edges if e.get("type") in {"rdfs:subClassOf", "skos:broader"}]
|
||||
subclasses = [e["source"] for e in in_edges if e.get("type") in {"rdfs:subClassOf", "skos:broader"}]
|
||||
domain = [e["target"] for e in out_edges if e.get("type") == "rdfs:domain"]
|
||||
range_ = [e["target"] for e in out_edges if e.get("type") == "rdfs:range"]
|
||||
|
||||
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
instance_count = sum(1 for n in all_nodes if n.get("type") == entity_uri)
|
||||
|
||||
return EntityDetailResponse(
|
||||
uri=entity_uri, label=label,
|
||||
type=ntype, entity_type=_classify_node_type(ntype),
|
||||
definition=definition,
|
||||
source_ontology=props.get("scheme_uri"),
|
||||
superclasses=superclasses, subclasses=subclasses,
|
||||
domain=domain, range=range_,
|
||||
instance_count=instance_count, properties=props,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/skos/schemes", response_model=List[SKOSScheme])
|
||||
async def list_skos_schemes(session: GraphSession = Depends(get_session)):
|
||||
nodes, _ = await asyncio.to_thread(
|
||||
session.get_nodes, node_type="skos:ConceptScheme", skip=0, limit=999_999
|
||||
)
|
||||
# Count concepts per scheme from edges
|
||||
all_edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
concept_counts: Dict[str, int] = {}
|
||||
for edge in all_edges:
|
||||
if edge.get("type") in {"skos:inScheme", "skos:topConceptOf"}:
|
||||
concept_counts[edge["target"]] = concept_counts.get(edge["target"], 0) + 1
|
||||
elif edge.get("type") == "skos:hasTopConcept":
|
||||
concept_counts[edge["source"]] = concept_counts.get(edge["source"], 0) + 1
|
||||
|
||||
result = []
|
||||
for node in nodes:
|
||||
props = node.get("properties", {})
|
||||
nid = node.get("id", "")
|
||||
result.append(SKOSScheme(
|
||||
uri=nid,
|
||||
title=_node_label(node),
|
||||
description=props.get("description") or props.get("skos:definition"),
|
||||
concept_count=concept_counts.get(nid, 0),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/skos/concept/{concept_uri:path}", response_model=SKOSConceptDetail)
|
||||
async def get_skos_concept(
|
||||
concept_uri: str,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, concept_uri)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail="Concept not found.")
|
||||
|
||||
props = node.get("properties", {})
|
||||
out_edges, _ = await asyncio.to_thread(session.get_edges, source=concept_uri, skip=0, limit=9999)
|
||||
in_edges, _ = await asyncio.to_thread(session.get_edges, target=concept_uri, skip=0, limit=9999)
|
||||
|
||||
def collect_out(rel: str) -> List[str]:
|
||||
return [e["target"] for e in out_edges if e.get("type") == rel]
|
||||
|
||||
def collect_in(rel: str) -> List[str]:
|
||||
return [e["source"] for e in in_edges if e.get("type") == rel]
|
||||
|
||||
pref_label = props.get("pref_label") or props.get("skos:prefLabel") or _node_label(node)
|
||||
alt_labels = props.get("alt_labels") or props.get("skos:altLabel") or []
|
||||
if isinstance(alt_labels, str):
|
||||
alt_labels = [alt_labels]
|
||||
hidden_labels = props.get("skos:hiddenLabel") or []
|
||||
if isinstance(hidden_labels, str):
|
||||
hidden_labels = [hidden_labels]
|
||||
|
||||
scheme_uri = props.get("scheme_uri")
|
||||
if not scheme_uri:
|
||||
candidates = collect_out("skos:inScheme") or collect_out("skos:topConceptOf")
|
||||
scheme_uri = candidates[0] if candidates else None
|
||||
|
||||
return SKOSConceptDetail(
|
||||
uri=concept_uri,
|
||||
pref_label=pref_label,
|
||||
alt_labels=list(alt_labels),
|
||||
hidden_labels=list(hidden_labels),
|
||||
definition=props.get("definition") or props.get("skos:definition"),
|
||||
scope_note=props.get("skos:scopeNote"),
|
||||
editorial_note=props.get("skos:editorialNote"),
|
||||
broader=collect_out("skos:broader") + collect_in("skos:narrower"),
|
||||
narrower=collect_out("skos:narrower") + collect_in("skos:broader"),
|
||||
related=collect_out("skos:related"),
|
||||
exact_match=collect_out("skos:exactMatch"),
|
||||
close_match=collect_out("skos:closeMatch"),
|
||||
broad_match=collect_out("skos:broadMatch"),
|
||||
narrow_match=collect_out("skos:narrowMatch"),
|
||||
scheme_uri=scheme_uri,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wildcard management endpoints (must come after specific routes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/{ontology_uri:path}")
|
||||
async def remove_ontology(ontology_uri: str, request: Request):
|
||||
registry = _get_registry(request)
|
||||
if ontology_uri not in registry:
|
||||
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
||||
del registry[ontology_uri]
|
||||
return {"status": "removed", "uri": ontology_uri}
|
||||
|
||||
|
||||
@router.patch("/{ontology_uri:path}/toggle", response_model=ToggleResponse)
|
||||
async def toggle_ontology(ontology_uri: str, request: Request):
|
||||
registry = _get_registry(request)
|
||||
if ontology_uri not in registry:
|
||||
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
||||
entry = registry[ontology_uri]
|
||||
entry.enabled = not entry.enabled
|
||||
return ToggleResponse(uri=ontology_uri, enabled=entry.enabled)
|
||||
|
||||
|
||||
@router.post("/{ontology_uri:path}/refresh", response_model=RefreshResponse)
|
||||
async def refresh_ontology(
|
||||
ontology_uri: str,
|
||||
request: Request,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
registry = _get_registry(request)
|
||||
if ontology_uri not in registry:
|
||||
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
||||
entry = registry[ontology_uri]
|
||||
if not entry.source_url:
|
||||
raise HTTPException(status_code=422, detail="No source URL to refresh from.")
|
||||
|
||||
raw = await asyncio.to_thread(_fetch_url_sync, entry.source_url)
|
||||
content_str = raw.decode("utf-8", errors="replace")
|
||||
|
||||
try:
|
||||
nodes, edges, _ = await asyncio.to_thread(
|
||||
_parse_rdf_sync, content_str.encode("utf-8"), entry.format
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Refresh parse error: {exc}") from exc
|
||||
|
||||
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
|
||||
edges_added = await asyncio.to_thread(session.add_edges, edges)
|
||||
entry.loaded_at = datetime.now(UTC).isoformat()
|
||||
|
||||
return RefreshResponse(uri=ontology_uri, nodes_added=nodes_added, edges_added=edges_added)
|
||||
@@ -5,20 +5,14 @@ Temporal routes for snapshots, diffs, and pattern detection.
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone, UTC
|
||||
from datetime import datetime, timezone, UTC
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import (
|
||||
DistanceEvent,
|
||||
DistanceHistoryResponse,
|
||||
DistanceSnapshot,
|
||||
TemporalDiffResponse,
|
||||
TemporalPatternResponse,
|
||||
)
|
||||
from ..schemas import TemporalDiffResponse, TemporalPatternResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -126,122 +120,3 @@ async def temporal_bounds(
|
||||
):
|
||||
bounds = await asyncio.to_thread(session.get_temporal_bounds)
|
||||
return TemporalBoundsResponse(**bounds)
|
||||
|
||||
|
||||
@router.get("/distance-history", response_model=DistanceHistoryResponse)
|
||||
async def distance_history(
|
||||
source: str = Query(..., description="Source node ID"),
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
metric: str = Query("hops", description="Distance metric: hops | weighted"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-9 — Track distance changes between two nodes across temporal snapshots."""
|
||||
from ...utils.helpers import classify_path_distance
|
||||
|
||||
bounds = await asyncio.to_thread(session.get_temporal_bounds)
|
||||
min_bound_str = bounds.get("min")
|
||||
max_bound_str = bounds.get("max")
|
||||
|
||||
if not min_bound_str or not max_bound_str:
|
||||
# No temporal data — return current-only snapshot
|
||||
pf = session.path_finder
|
||||
hop_count: Optional[int] = None
|
||||
if pf is not None:
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
path_fn = pf.dijkstra_shortest_path if metric == "weighted" else pf.bfs_shortest_path
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, source, target)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else None
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"distance_history path computation failed for source=%r target=%r metric=%r: %s",
|
||||
source, target, metric, exc, exc_info=True,
|
||||
)
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
snap = DistanceSnapshot(
|
||||
timestamp=now,
|
||||
hop_count=hop_count,
|
||||
distance_band=classify_path_distance(hop_count) if hop_count is not None else "distant",
|
||||
)
|
||||
return DistanceHistoryResponse(
|
||||
source_id=source, target_id=target, metric=metric,
|
||||
history=[snap], events=[],
|
||||
)
|
||||
|
||||
min_bound = _parse_query_dt(min_bound_str)
|
||||
max_bound = _parse_query_dt(max_bound_str)
|
||||
|
||||
# Sample up to 10 snapshots evenly between min and max
|
||||
total_seconds = max(1, int((max_bound - min_bound).total_seconds()))
|
||||
step = total_seconds / min(10, total_seconds)
|
||||
sample_times = [
|
||||
min_bound + timedelta(seconds=int(i * step))
|
||||
for i in range(11)
|
||||
]
|
||||
|
||||
pf = session.path_finder
|
||||
history: List[DistanceSnapshot] = []
|
||||
events: List[DistanceEvent] = []
|
||||
prev_hop: Optional[int] = None
|
||||
|
||||
for sample_time in sample_times:
|
||||
active_nodes = await asyncio.to_thread(session.get_active_nodes, at_time=sample_time)
|
||||
active_ids = {n.get("id") for n in active_nodes if n.get("id")}
|
||||
hop_count = None
|
||||
if source in active_ids and target in active_ids and pf is not None:
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(
|
||||
session.build_graph_dict, list(active_ids)
|
||||
)
|
||||
path_fn = pf.dijkstra_shortest_path if metric == "weighted" else pf.bfs_shortest_path
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, source, target)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else None
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"distance_history path computation failed for source=%r target=%r at=%s metric=%s: %s",
|
||||
source, target, sample_time.isoformat(), metric, exc, exc_info=True,
|
||||
)
|
||||
hop_count = None
|
||||
|
||||
band = classify_path_distance(hop_count) if hop_count is not None else "distant"
|
||||
snap = DistanceSnapshot(timestamp=sample_time, hop_count=hop_count, distance_band=band)
|
||||
history.append(snap)
|
||||
|
||||
# Detect events relative to previous snapshot
|
||||
if prev_hop is not None or hop_count is not None:
|
||||
if prev_hop is None and hop_count is not None:
|
||||
events.append(DistanceEvent(
|
||||
timestamp=sample_time,
|
||||
event_type="reconnected",
|
||||
hop_count_before=None,
|
||||
hop_count_after=hop_count,
|
||||
description=f"Nodes reconnected at {hop_count} hop(s) on {sample_time.date()}.",
|
||||
))
|
||||
elif prev_hop is not None and hop_count is None:
|
||||
events.append(DistanceEvent(
|
||||
timestamp=sample_time,
|
||||
event_type="disconnected",
|
||||
hop_count_before=prev_hop,
|
||||
hop_count_after=None,
|
||||
description=f"Nodes became unreachable on {sample_time.date()}.",
|
||||
))
|
||||
elif prev_hop is not None and hop_count is not None and hop_count != prev_hop:
|
||||
etype = "convergence" if hop_count < prev_hop else "divergence"
|
||||
events.append(DistanceEvent(
|
||||
timestamp=sample_time,
|
||||
event_type=etype,
|
||||
hop_count_before=prev_hop,
|
||||
hop_count_after=hop_count,
|
||||
description=(
|
||||
f"Nodes {etype}d from {prev_hop} hops to {hop_count} hops "
|
||||
f"on {sample_time.date()}."
|
||||
),
|
||||
))
|
||||
prev_hop = hop_count
|
||||
|
||||
return DistanceHistoryResponse(
|
||||
source_id=source, target_id=target, metric=metric,
|
||||
history=history, events=events,
|
||||
)
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
Shared Pydantic schemas for the Semantica Knowledge Explorer API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -71,13 +70,6 @@ class PathResponse(BaseModel):
|
||||
directed: bool = True
|
||||
hop_count: int = 0
|
||||
distance_band: str = "direct"
|
||||
# FR-4 enrichment fields — all optional; existing callers unaffected
|
||||
semantic_similarity: Optional[float] = None
|
||||
path_coherence_score: Optional[float] = None
|
||||
confidence_decay: Optional[float] = None
|
||||
bottleneck_node: Optional[str] = None
|
||||
alternative_path_count: int = 0
|
||||
interpretation: str = ""
|
||||
|
||||
|
||||
class GraphStatsResponse(BaseModel):
|
||||
@@ -92,19 +84,11 @@ class SearchRequest(BaseModel):
|
||||
query: str
|
||||
filters: Dict[str, Any] = Field(default_factory=dict)
|
||||
limit: int = Field(default=20, ge=1, le=200)
|
||||
# FR-7 proximity constraint fields
|
||||
anchor_node: Optional[str] = None
|
||||
max_hops: Optional[int] = None
|
||||
min_semantic_similarity: Optional[float] = None
|
||||
rank_by: Literal["relevance", "proximity", "hybrid"] = "relevance"
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
node: NodeResponse
|
||||
score: float = 0.0
|
||||
# FR-7 distance metadata
|
||||
hop_distance: Optional[int] = None
|
||||
semantic_similarity: Optional[float] = None
|
||||
|
||||
|
||||
class SearchResultResponse(BaseModel):
|
||||
@@ -324,91 +308,3 @@ class ProvenanceEdge(BaseModel):
|
||||
class ProvenanceResponse(BaseModel):
|
||||
nodes: List[ProvenanceNode]
|
||||
edges: List[ProvenanceEdge]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-6 — Distance Matrix API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceMatrixRequest(BaseModel):
|
||||
node_ids: List[str]
|
||||
metric: Literal["hops", "weighted", "semantic"] = "hops"
|
||||
|
||||
|
||||
class DistanceMatrixResponse(BaseModel):
|
||||
nodes: List[str]
|
||||
metric: str
|
||||
matrix: List[List[Optional[float]]]
|
||||
unreachable_pairs: List[Tuple[str, str]] = Field(default_factory=list)
|
||||
computation_time_ms: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-3 backend — Semantic Neighborhood
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SemanticNeighborItem(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
content: str = ""
|
||||
similarity: float
|
||||
hop_distance: Optional[int] = None
|
||||
|
||||
|
||||
class SemanticNeighborhoodResponse(BaseModel):
|
||||
anchor_node: str
|
||||
neighbors: List[SemanticNeighborItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-8 — Causal Distance Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CausalDistanceReport(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
causal_path: List[str]
|
||||
causal_hop_count: int
|
||||
intermediate_decisions: List[str]
|
||||
confidence_decay: float
|
||||
weakest_link: Optional[Dict[str, Any]] = None
|
||||
interpretation: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-9 — Temporal Distance Alerts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceSnapshot(BaseModel):
|
||||
timestamp: datetime
|
||||
hop_count: Optional[int] = None
|
||||
distance_band: str
|
||||
|
||||
|
||||
class DistanceEvent(BaseModel):
|
||||
timestamp: datetime
|
||||
event_type: Literal["convergence", "divergence", "disconnected", "reconnected"]
|
||||
hop_count_before: Optional[int] = None
|
||||
hop_count_after: Optional[int] = None
|
||||
description: str
|
||||
|
||||
|
||||
class DistanceHistoryResponse(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
metric: str
|
||||
history: List[DistanceSnapshot]
|
||||
events: List[DistanceEvent]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-10 — Distance-Enriched Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceExportRequest(BaseModel):
|
||||
format: Literal["csv", "jsonl"] = "csv"
|
||||
node_subset: Optional[List[str]] = None
|
||||
include: List[str] = Field(
|
||||
default_factory=lambda: ["source_id", "target_id", "hop_count", "distance_band"],
|
||||
)
|
||||
|
||||
@@ -162,7 +162,6 @@ License: MIT
|
||||
"""
|
||||
|
||||
from .arango_aql_exporter import ArangoAQLExporter
|
||||
from .distance_exporter import DistanceExporter
|
||||
from .config import ExportConfig, export_config
|
||||
|
||||
try:
|
||||
@@ -221,7 +220,6 @@ __all__ = [
|
||||
# Core Exporters
|
||||
"ArrowExporter",
|
||||
"ArangoAQLExporter",
|
||||
"DistanceExporter",
|
||||
"RDFExporter",
|
||||
"RDFSerializer",
|
||||
"RDFValidator",
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
Distance-Enriched Export (FR-10)
|
||||
|
||||
Exports pairwise node distance metrics — hop count, weighted distance,
|
||||
semantic similarity, distance band, betweenness centrality — in CSV or
|
||||
JSONL format for downstream ML pipelines (GNN training, clustering,
|
||||
link prediction).
|
||||
|
||||
Python API:
|
||||
exporter = DistanceExporter(graph)
|
||||
df = exporter.to_dataframe(include=["hops", "semantic_similarity", "distance_band"])
|
||||
exporter.to_csv("distances.csv")
|
||||
exporter.to_jsonl("distances.jsonl")
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_KG_AVAILABLE = False
|
||||
try:
|
||||
from ..kg import PathFinder, SimilarityCalculator, CentralityCalculator
|
||||
_KG_AVAILABLE = True
|
||||
except ImportError as exc:
|
||||
logger.debug("KG components not available; distance exporter will run in reduced mode: %s", exc)
|
||||
|
||||
_ALL_COLUMNS = [
|
||||
"source_id", "source_type", "target_id", "target_type",
|
||||
"hop_count", "weighted_distance", "semantic_similarity",
|
||||
"distance_band", "source_betweenness", "target_betweenness",
|
||||
]
|
||||
|
||||
|
||||
class DistanceExporter:
|
||||
"""Compute and export pairwise distance metrics for a ContextGraph."""
|
||||
|
||||
def __init__(self, graph: Any) -> None:
|
||||
self.graph = graph
|
||||
self._path_finder = PathFinder() if _KG_AVAILABLE else None
|
||||
self._similarity = SimilarityCalculator() if _KG_AVAILABLE else None
|
||||
self._centrality = CentralityCalculator() if _KG_AVAILABLE else None
|
||||
|
||||
def _build_graph_dict(self) -> Dict[str, Any]:
|
||||
nodes = [
|
||||
{"id": n.node_id, "type": n.node_type, "content": n.content, "properties": n.properties}
|
||||
for n in self.graph.nodes.values()
|
||||
]
|
||||
edges_raw = getattr(self.graph, "edges", [])
|
||||
edges = [
|
||||
{
|
||||
"id": e.edge_id, "source": e.source_id, "target": e.target_id,
|
||||
"type": e.edge_type, "weight": e.weight,
|
||||
}
|
||||
for e in edges_raw
|
||||
]
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
def _node_type(self, node_id: str) -> str:
|
||||
node = getattr(self.graph, "nodes", {}).get(node_id)
|
||||
return getattr(node, "node_type", "") if node else ""
|
||||
|
||||
def _betweenness(self, graph_dict: Dict[str, Any]) -> Dict[str, float]:
|
||||
if self._centrality is None:
|
||||
return {}
|
||||
try:
|
||||
result = self._centrality.calculate_betweenness_centrality(graph_dict)
|
||||
return result.get("betweenness", {}) if isinstance(result, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _hop_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[int]:
|
||||
if self._path_finder is None:
|
||||
return None
|
||||
try:
|
||||
result = self._path_finder.bfs_shortest_path(graph_dict, src, tgt)
|
||||
path = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
return len(path) - 1 if path else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _weighted_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
|
||||
if self._path_finder is None:
|
||||
return None
|
||||
try:
|
||||
result = self._path_finder.dijkstra_shortest_path(graph_dict, src, tgt)
|
||||
if isinstance(result, dict):
|
||||
return float(result.get("total_weight", len(result.get("path", [])) - 1))
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _semantic_similarity(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
|
||||
if self._similarity is None:
|
||||
return None
|
||||
try:
|
||||
sim = self._similarity.cosine_similarity(graph_dict, src, tgt)
|
||||
return float(sim) if isinstance(sim, (int, float)) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def compute_pairs(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Compute all pairwise distance metrics and return as a list of dicts."""
|
||||
include_set = set(include or _ALL_COLUMNS)
|
||||
graph_dict = self._build_graph_dict()
|
||||
|
||||
node_ids = node_subset or list(self.graph.nodes.keys())
|
||||
|
||||
betweenness: Dict[str, float] = {}
|
||||
if "source_betweenness" in include_set or "target_betweenness" in include_set:
|
||||
betweenness = self._betweenness(graph_dict)
|
||||
|
||||
rows = []
|
||||
for i, src in enumerate(node_ids):
|
||||
for tgt in node_ids:
|
||||
if src == tgt:
|
||||
continue
|
||||
row: Dict[str, Any] = {}
|
||||
if "source_id" in include_set:
|
||||
row["source_id"] = src
|
||||
if "source_type" in include_set:
|
||||
row["source_type"] = self._node_type(src)
|
||||
if "target_id" in include_set:
|
||||
row["target_id"] = tgt
|
||||
if "target_type" in include_set:
|
||||
row["target_type"] = self._node_type(tgt)
|
||||
|
||||
hop_count: Optional[int] = None
|
||||
if "hop_count" in include_set or "distance_band" in include_set:
|
||||
hop_count = self._hop_distance(graph_dict, src, tgt)
|
||||
if "hop_count" in include_set:
|
||||
row["hop_count"] = hop_count
|
||||
|
||||
if "weighted_distance" in include_set:
|
||||
row["weighted_distance"] = self._weighted_distance(graph_dict, src, tgt)
|
||||
|
||||
if "semantic_similarity" in include_set:
|
||||
row["semantic_similarity"] = self._semantic_similarity(graph_dict, src, tgt)
|
||||
|
||||
if "distance_band" in include_set:
|
||||
row["distance_band"] = classify_path_distance(hop_count) if hop_count is not None else "distant"
|
||||
|
||||
if "source_betweenness" in include_set:
|
||||
row["source_betweenness"] = betweenness.get(src)
|
||||
if "target_betweenness" in include_set:
|
||||
row["target_betweenness"] = betweenness.get(tgt)
|
||||
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def to_dataframe(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> Any:
|
||||
"""Return a pandas DataFrame of pairwise distances."""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError("pandas is required for to_dataframe()") from exc
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
def to_csv(
|
||||
self,
|
||||
path: str,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Write pairwise distances to a CSV file."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
if not rows:
|
||||
with open(path, "w", newline="", encoding="utf-8") as fh:
|
||||
fh.write("")
|
||||
return
|
||||
fieldnames = list(rows[0].keys())
|
||||
with open(path, "w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
def to_jsonl(
|
||||
self,
|
||||
path: str,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Write pairwise distances to a JSONL file (one JSON object per line)."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row, default=str) + "\n")
|
||||
|
||||
def to_csv_string(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Return CSV as a string (for API responses)."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
if not rows:
|
||||
return ""
|
||||
buf = io.StringIO()
|
||||
fieldnames = list(rows[0].keys())
|
||||
writer = csv.DictWriter(buf, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue()
|
||||
|
||||
def to_jsonl_string(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Return JSONL as a string (for API responses)."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
return "\n".join(json.dumps(row, default=str) for row in rows)
|
||||
@@ -1,212 +0,0 @@
|
||||
"""Targeted regression tests for all 13 Qodo review fixes on the Distance Intelligence PR."""
|
||||
import re
|
||||
import inspect
|
||||
|
||||
|
||||
# ── bug_003: include_distance_metadata=False is the backward-compat default ───
|
||||
|
||||
def test_bug003_metadata_absent_by_default():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related")
|
||||
neighbors = g.get_neighbors("A")
|
||||
assert len(neighbors) == 1
|
||||
assert "hop" in neighbors[0]
|
||||
assert "distance_band" not in neighbors[0], (
|
||||
f"distance_band should be absent by default; got keys: {list(neighbors[0].keys())}"
|
||||
)
|
||||
assert "confidence_decay" not in neighbors[0]
|
||||
assert "path_to_anchor" not in neighbors[0]
|
||||
|
||||
|
||||
def test_bug003_metadata_present_with_flag():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related")
|
||||
neighbors = g.get_neighbors("A", include_distance_metadata=True)
|
||||
assert len(neighbors) == 1
|
||||
assert "distance_band" in neighbors[0]
|
||||
assert "confidence_decay" in neighbors[0]
|
||||
assert "path_to_anchor" in neighbors[0]
|
||||
|
||||
|
||||
def test_bug003_get_neighbor_distances_still_works():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related", weight=0.9)
|
||||
nd = g.get_neighbor_distances("A")
|
||||
assert len(nd) == 1
|
||||
assert nd[0]["distance_band"] == "direct"
|
||||
assert abs(nd[0]["confidence_decay"] - 0.9) < 1e-9
|
||||
assert "path_to_anchor" in nd[0]
|
||||
|
||||
|
||||
# ── bug_004: weakest_link standardized to edge_weight key ─────────────────────
|
||||
|
||||
def test_bug004_weakest_link_uses_edge_weight_key():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "decision")
|
||||
g.add_node("B", "decision")
|
||||
g.add_node("C", "decision")
|
||||
g.add_edge("A", "B", "causes", weight=0.8)
|
||||
g.add_edge("B", "C", "causes", weight=0.5)
|
||||
analyzer = CausalChainAnalyzer(g)
|
||||
report = analyzer.interpret_causal_distance("A", "C")
|
||||
wl = report.get("weakest_link")
|
||||
assert wl is not None, "weakest_link must be set for a 2-hop causal path"
|
||||
assert "edge_weight" in wl, f"Expected edge_weight key, got: {list(wl.keys())}"
|
||||
assert "weight" not in wl, f"Old key 'weight' should be absent; got: {list(wl.keys())}"
|
||||
assert wl["edge_weight"] == 0.5
|
||||
|
||||
|
||||
def test_bug004_causal_distance_report_schema_validates():
|
||||
from semantica.explorer.schemas import CausalDistanceReport
|
||||
report = CausalDistanceReport(
|
||||
source_id="A",
|
||||
target_id="C",
|
||||
causal_path=["A", "B", "C"],
|
||||
causal_hop_count=2,
|
||||
intermediate_decisions=["B"],
|
||||
confidence_decay=0.4,
|
||||
weakest_link={"source": "A", "target": "B", "edge_weight": 0.5},
|
||||
interpretation="Test path",
|
||||
)
|
||||
assert report.weakest_link["edge_weight"] == 0.5
|
||||
|
||||
|
||||
# ── qual_003: _distance_band static methods removed; classify_path_distance used ─
|
||||
|
||||
def test_qual003_distance_band_removed_from_causal_analyzer():
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
assert not hasattr(CausalChainAnalyzer, "_distance_band")
|
||||
ca_src = inspect.getsource(CausalChainAnalyzer)
|
||||
assert "def _distance_band" not in ca_src
|
||||
assert "classify_path_distance" in ca_src
|
||||
|
||||
|
||||
def test_qual003_distance_band_removed_from_agent_context():
|
||||
import semantica.context.agent_context as ac_mod
|
||||
ac_src = inspect.getsource(ac_mod)
|
||||
assert "def _distance_band" not in ac_src
|
||||
assert "classify_path_distance" in ac_src
|
||||
|
||||
|
||||
# ── bug_005: timedelta arithmetic — no timetuple reconstruction ───────────────
|
||||
|
||||
def test_bug005_no_timetuple_hack_in_distance_history():
|
||||
from semantica.explorer.routes import temporal
|
||||
src = inspect.getsource(temporal.distance_history)
|
||||
assert "timetuple" not in src, "Old timetuple hack should be gone"
|
||||
assert "__import__" not in src, "Dynamic import hack should be gone"
|
||||
assert "timedelta(seconds" in src
|
||||
|
||||
|
||||
# ── sec_001: node_subset capped at 200 ────────────────────────────────────────
|
||||
|
||||
def test_sec001_node_subset_limit_constant_exists():
|
||||
from semantica.explorer.routes.export_import import _DISTANCE_EXPORT_MAX_NODES
|
||||
assert _DISTANCE_EXPORT_MAX_NODES == 200
|
||||
|
||||
|
||||
def test_sec001_export_endpoint_validates_subset_size():
|
||||
from semantica.explorer.routes import export_import
|
||||
src = inspect.getsource(export_import.export_distance_enriched)
|
||||
assert "_DISTANCE_EXPORT_MAX_NODES" in src
|
||||
assert "status_code=413" in src
|
||||
|
||||
|
||||
# ── sec_002: distance matrix upper-triangle only ──────────────────────────────
|
||||
|
||||
def test_sec002_distance_matrix_upper_triangle_loop():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(graph.distance_matrix)
|
||||
assert "range(i + 1, n)" in src, "Should use upper-triangle loop"
|
||||
assert "matrix[j][i]" in src, "Should mirror lower triangle"
|
||||
|
||||
|
||||
# ── bug_006: O(L) edge weight index built once ────────────────────────────────
|
||||
|
||||
def test_bug006_edge_weight_index_built_once():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(getattr(graph, "_find_path_impl", graph.find_path))
|
||||
assert "edge_weight_index" in src
|
||||
assert "for edge in edge_data:" not in src, "Old O(E*L) loop should be gone"
|
||||
|
||||
|
||||
# ── bug_007: original result id not overwritten ───────────────────────────────
|
||||
|
||||
def test_bug007_original_id_not_overwritten():
|
||||
from semantica.context import agent_context
|
||||
src = inspect.getsource(agent_context.AgentContext._apply_proximity_metadata)
|
||||
assert (
|
||||
'"graph_node_id": result_id' in src
|
||||
or "'graph_node_id': result_id" in src
|
||||
)
|
||||
assert '"id": result_id' not in src, "id should not be overwritten by result_id"
|
||||
|
||||
|
||||
# ── qual_002: no bare except:pass in enrichment blocks ───────────────────────
|
||||
|
||||
def test_qual002_no_bare_except_pass_in_find_path():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(getattr(graph, "_find_path_impl", graph.find_path))
|
||||
bare_pass = re.findall(r"except Exception:\s*\n\s*pass", src)
|
||||
assert not bare_pass, f"Found bare except:pass: {bare_pass}"
|
||||
assert "logger.debug" in src
|
||||
|
||||
|
||||
# ── TypeScript fixes — checked via raw file reads ─────────────────────────────
|
||||
|
||||
TS_BEHAVIOR = (
|
||||
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
|
||||
r"\GraphWorkspace\behaviors\pathHighlightBehavior.ts"
|
||||
)
|
||||
TS_WORKSPACE = (
|
||||
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
|
||||
r"\GraphWorkspace\GraphWorkspace.tsx"
|
||||
)
|
||||
|
||||
|
||||
def test_bug008_sweep_generation_counter():
|
||||
with open(TS_BEHAVIOR, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "sweepGeneration" in src, "Generation counter variable must exist"
|
||||
assert "gen !== sweepGeneration" in src, "Stale-callback guard must exist"
|
||||
assert "sweepGeneration++" in src, "Counter must be incremented on cancel"
|
||||
|
||||
|
||||
def test_bug001_semantic_neighborhood_uses_top_k():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert (
|
||||
"top_k=50" in src or 'top_k: "50"' in src
|
||||
), "Should use top_k (not limit) to match backend param"
|
||||
idx = src.find("semantic-neighborhood?")
|
||||
snippet = src[idx: idx + 100]
|
||||
assert "limit=" not in snippet, f"Found 'limit=' in URL snippet: {snippet!r}"
|
||||
|
||||
|
||||
def test_bug002_semantic_neighborhood_response_type_complete():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "anchor_node: string" in src
|
||||
assert "hop_distance?" in src
|
||||
|
||||
|
||||
def test_qual001_ego_heatmap_merged_into_single_effect():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "egoModeEnabled, egoMaxHops, heatmapEnabled, selectedNodeId" in src, (
|
||||
"Combined dep array must be present"
|
||||
)
|
||||
# The old separate dep arrays must not exist
|
||||
assert "], [egoModeEnabled, egoMaxHops, selectedNodeId]" not in src
|
||||
assert "], [heatmapEnabled, selectedNodeId]" not in src
|
||||
@@ -1,97 +0,0 @@
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
|
||||
|
||||
def test_get_neighbor_distances_tracks_path_decay_and_band():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node("A", "entity", "Anchor")
|
||||
graph.add_node("B", "entity", "Bridge")
|
||||
graph.add_node("C", "decision", "Decision")
|
||||
graph.add_edge("A", "B", "influences", weight=0.9)
|
||||
graph.add_edge("B", "C", "influences", weight=0.7)
|
||||
|
||||
neighbors = graph.get_neighbor_distances("A", hops=2, min_confidence=0.5)
|
||||
c_neighbor = next(item for item in neighbors if item["id"] == "C")
|
||||
|
||||
assert c_neighbor["hop"] == 2
|
||||
assert c_neighbor["distance_band"] == "near"
|
||||
assert c_neighbor["confidence_decay"] == 0.63
|
||||
assert c_neighbor["path_to_anchor"] == ["A", "B", "C"]
|
||||
|
||||
|
||||
def test_trace_decision_causality_returns_auditable_chain_dicts():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
first = graph.record_decision(
|
||||
category="risk",
|
||||
scenario="Approve initial risk policy",
|
||||
reasoning="Baseline risk controls look sound",
|
||||
outcome="approved",
|
||||
confidence=0.8,
|
||||
entities=["account_123"],
|
||||
)
|
||||
second = graph.record_decision(
|
||||
category="risk",
|
||||
scenario="Approve follow-up risk exception",
|
||||
reasoning="Prior account controls still apply",
|
||||
outcome="approved",
|
||||
confidence=0.9,
|
||||
entities=["account_123"],
|
||||
)
|
||||
graph._decisions[first]["timestamp"] = 1
|
||||
graph._decisions[second]["timestamp"] = 2
|
||||
|
||||
chains = graph.trace_decision_causality(second, max_depth=2)
|
||||
|
||||
assert chains
|
||||
assert chains[0]["hop_count"] == 1
|
||||
assert chains[0]["distance_band"] == "direct"
|
||||
assert chains[0]["weakest_link"]["from"] == first
|
||||
assert chains[0]["hops"][0]["to"] == second
|
||||
assert "confidence" in chains[0]["interpretation"]
|
||||
assert list(chains[0])[0]["from"] == first
|
||||
|
||||
|
||||
def test_analyze_decision_influence_exposes_score_breakdown():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
source = graph.record_decision(
|
||||
category="loan",
|
||||
scenario="Approve secured loan",
|
||||
reasoning="Collateral and income verified",
|
||||
outcome="approved",
|
||||
confidence=0.9,
|
||||
entities=["borrower_1"],
|
||||
)
|
||||
graph.record_decision(
|
||||
category="loan",
|
||||
scenario="Review related refinance",
|
||||
reasoning="Same borrower and collateral",
|
||||
outcome="review",
|
||||
confidence=0.8,
|
||||
entities=["borrower_1"],
|
||||
)
|
||||
|
||||
result = graph.analyze_decision_influence(source)
|
||||
|
||||
assert result["influence_scores"]
|
||||
score = result["influence_scores"][0]
|
||||
assert set(score["score_breakdown"]) == {
|
||||
"entity_overlap",
|
||||
"category_match",
|
||||
"temporal_proximity",
|
||||
}
|
||||
assert score["is_direct"] is True
|
||||
|
||||
|
||||
def test_cross_graph_path_traverses_link_boundary():
|
||||
left = ContextGraph(advanced_analytics=False)
|
||||
right = ContextGraph(advanced_analytics=False)
|
||||
left.add_node("A", "entity", "Left")
|
||||
right.add_node("B", "entity", "Right")
|
||||
left.link_graph(right, "A", "B")
|
||||
|
||||
path = left.cross_graph_path("A", right, "B")
|
||||
|
||||
assert path["reachable"] is True
|
||||
assert path["hop_count"] == 1
|
||||
assert path["cross_graph_links_used"] == 1
|
||||
assert path["distance_band"] == "direct"
|
||||
assert path["path"] == [(left.graph_id, "A"), (right.graph_id, "B")]
|
||||
@@ -733,10 +733,7 @@ def _make_path_session() -> GraphSession:
|
||||
cg = ContextGraph(advanced_analytics=False)
|
||||
cg.add_node("A", node_type="entity", content="Node A")
|
||||
cg.add_node("B", node_type="entity", content="Node B")
|
||||
cg.add_node("gene/protein:6164", node_type="gene/protein", content="RPL34")
|
||||
cg.add_node("disease/term:1", node_type="disease", content="Slash target")
|
||||
cg.add_edge("A", "B", edge_type="connects")
|
||||
cg.add_edge("gene/protein:6164", "disease/term:1", edge_type="connects")
|
||||
|
||||
session = GraphSession(cg)
|
||||
|
||||
@@ -745,7 +742,6 @@ def _make_path_session() -> GraphSession:
|
||||
# PathFinder; this mimics how a KG-backed session would expose the graph.
|
||||
digraph = nx.DiGraph()
|
||||
digraph.add_edge("A", "B")
|
||||
digraph.add_edge("gene/protein:6164", "disease/term:1")
|
||||
session.build_graph_dict = lambda node_ids=None: digraph # type: ignore[method-assign]
|
||||
|
||||
return session
|
||||
@@ -796,22 +792,6 @@ class TestBidirectionalPathRoute:
|
||||
assert body["path"] == ["B", "A"]
|
||||
assert body["directed"] is False
|
||||
|
||||
def test_query_path_route_supports_slash_node_ids(self, path_client):
|
||||
"""Query-param path route must support arbitrary graph ids with slashes."""
|
||||
resp = path_client.get(
|
||||
"/api/graph/path",
|
||||
params={
|
||||
"source": "gene/protein:6164",
|
||||
"target": "disease/term:1",
|
||||
"algorithm": "dijkstra",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["gene/protein:6164", "disease/term:1"]
|
||||
assert body["source"] == "gene/protein:6164"
|
||||
assert body["target"] == "disease/term:1"
|
||||
|
||||
def test_directed_false_forward_path_found(self, path_client):
|
||||
"""directed=false must not break the natural A→B direction."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B&directed=false")
|
||||
@@ -885,101 +865,6 @@ class TestBidirectionalPathRoute:
|
||||
from semantica.utils.helpers import classify_path_distance
|
||||
|
||||
|
||||
class _FakeSimilarity:
|
||||
"""Minimal similarity stub shared by slash-safe distance route tests.
|
||||
|
||||
Expects embeddings keyed on 'gene/protein:6164' with query vector [1, 0, 0]
|
||||
and returns a single neighbor result. Tests that need different behaviour
|
||||
can assign a lambda to instance.find_most_similar after construction.
|
||||
"""
|
||||
|
||||
def find_most_similar(self, embeddings, query_embedding, top_k=10):
|
||||
assert "gene/protein:6164" in embeddings
|
||||
assert query_embedding == [1.0, 0.0, 0.0]
|
||||
return [("disease/term:1", 0.74)]
|
||||
|
||||
|
||||
def _make_slash_node_session(*, with_embeddings: bool = True) -> GraphSession:
|
||||
"""Return an isolated GraphSession with slash-containing node IDs."""
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
kwargs = {"embedding": [1.0, 0.0, 0.0]} if with_embeddings else {}
|
||||
graph.add_node("gene/protein:6164", node_type="gene/protein", content="RPL34", **kwargs)
|
||||
graph.add_node(
|
||||
"disease/term:1",
|
||||
node_type="disease",
|
||||
content="Slash target",
|
||||
**({"embedding": [0.7, 0.2, 0.1]} if with_embeddings else {}),
|
||||
)
|
||||
session = GraphSession(graph)
|
||||
session._similarity = _FakeSimilarity()
|
||||
return session
|
||||
|
||||
|
||||
class TestSlashSafeDistanceRoutes:
|
||||
def test_query_semantic_neighborhood_supports_slash_node_ids(self):
|
||||
session = _make_slash_node_session(with_embeddings=True)
|
||||
app = create_app(session=session)
|
||||
with TestClient(app) as test_client:
|
||||
resp = test_client.get(
|
||||
"/api/graph/semantic-neighborhood",
|
||||
params={"node_id": "gene/protein:6164", "top_k": 50},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["anchor_node"] == "gene/protein:6164"
|
||||
assert body["neighbors"][0]["id"] == "disease/term:1"
|
||||
assert body["neighbors"][0]["similarity"] == 0.74
|
||||
|
||||
def test_legacy_semantic_neighborhood_still_works_for_simple_ids(self):
|
||||
"""Legacy path-segment route must still return 200 for slash-free node IDs."""
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node(
|
||||
"semantic_anchor",
|
||||
node_type="entity",
|
||||
content="Semantic anchor",
|
||||
embedding=[1.0, 0.0, 0.0],
|
||||
)
|
||||
graph.add_node(
|
||||
"semantic_neighbor",
|
||||
node_type="entity",
|
||||
content="Semantic neighbor",
|
||||
embedding=[0.8, 0.2, 0.0],
|
||||
)
|
||||
session = GraphSession(graph)
|
||||
fake = _FakeSimilarity()
|
||||
fake.find_most_similar = (
|
||||
lambda embeddings, query_embedding, top_k=10: [("semantic_neighbor", 0.8)]
|
||||
)
|
||||
session._similarity = fake
|
||||
app = create_app(session=session)
|
||||
with TestClient(app) as test_client:
|
||||
resp = test_client.get(
|
||||
"/api/graph/node/semantic_anchor/semantic-neighborhood?top_k=10"
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["anchor_node"] == "semantic_anchor"
|
||||
|
||||
def test_query_semantic_neighborhood_missing_node_returns_404(self, client):
|
||||
resp = client.get(
|
||||
"/api/graph/semantic-neighborhood",
|
||||
params={"node_id": "gene/protein:missing"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_query_semantic_neighborhood_without_embeddings_returns_503(self):
|
||||
session = _make_slash_node_session(with_embeddings=False)
|
||||
app = create_app(session=session)
|
||||
with TestClient(app) as test_client:
|
||||
resp = test_client.get(
|
||||
"/api/graph/semantic-neighborhood",
|
||||
params={"node_id": "gene/protein:6164", "top_k": 50},
|
||||
)
|
||||
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
class TestClassifyDistance:
|
||||
"""Unit tests covering all four band boundaries."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user