mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
saving
This commit is contained in:
parent
9e9307a2aa
commit
e26caa0b12
123 changed files with 3478 additions and 10078 deletions
25
ai-context/trustgraph-templates/.gitignore
vendored
25
ai-context/trustgraph-templates/.gitignore
vendored
|
|
@ -1,25 +0,0 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*~
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an approval
|
||||
from the project maintainers before applying the Apache License
|
||||
to your project.
|
||||
|
||||
Copyright 2026 Knownext Inc.
|
||||
Copyright 2026 Knownext Limited
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
|
||||
# Context Graph Demo
|
||||
|
||||
A React application that demonstrates
|
||||
[TrustGraph](https://trustgraph.ai/) context graph capabilities
|
||||
The demo provides an interactive graph visualisation, natural-language
|
||||
querying, explainability views, and ontology browsing — all powered by
|
||||
a TrustGraph backend. Load your own data to explore.
|
||||
|
||||
See it in action: [Context Graph demo video](https://www.youtube.com/watch?v=sWc7mkhITIo)
|
||||
|
||||
## Features
|
||||
|
||||
- **Graph view** — interactive force-directed graph of entities and
|
||||
relationships, with domain-based filtering
|
||||
- **Query view** — natural-language questions answered by the TrustGraph
|
||||
knowledge graph
|
||||
- **Explain view** — step-by-step explainability traces showing how
|
||||
answers were derived
|
||||
- **Data view** — browse the raw documents loaded into TrustGraph
|
||||
- **Ontology view** — explore the ontology (types and predicates)
|
||||
extracted from the dataset
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (v18+)
|
||||
- A running [TrustGraph](https://trustgraph.ai/) instance (tested with
|
||||
TrustGraph 2.1)
|
||||
|
||||
## Preparing TrustGraph
|
||||
|
||||
This demo requires TrustGraph to be running in ontology mode:
|
||||
|
||||
1. Launch a flow using the `ontology` flow blueprint.
|
||||
2. Load an OWL ontology into the workbench.
|
||||
3. Process your data using the new flow.
|
||||
|
||||
## Getting started
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/api/socket` (WebSocket) and other API
|
||||
routes to the TrustGraph API gateway at `localhost:8088`. If your
|
||||
TrustGraph instance is running on a different host or port, edit the
|
||||
proxy targets in `vite.config.js`.
|
||||
|
||||
Build for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2026 Knownext Inc. and Knownext Limited.
|
||||
Licensed under the Apache License 2.0 — see [LICENSE](LICENSE) for
|
||||
details.
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/tg.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TrustGraph Context Graph Demo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3080
ai-context/trustgraph-templates/package-lock.json
generated
3080
ai-context/trustgraph-templates/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"name": "retail-intelligence-demo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@trustgraph/react-provider": "^1.4.0",
|
||||
"@trustgraph/react-state": "^1.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
|
@ -1,310 +0,0 @@
|
|||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
||||
@prefix : <http://trustgraph.ai/retail#> .
|
||||
|
||||
# Ontology declaration
|
||||
<http://trustgraph.ai/retail> a owl:Ontology ;
|
||||
rdfs:label "TrustGraph Retail Intelligence Ontology" ;
|
||||
rdfs:comment "Ontology for retail ecosystem modeling: consumers, brands, retail channels, and AI agents" .
|
||||
|
||||
# =============================================================================
|
||||
# Classes
|
||||
# =============================================================================
|
||||
|
||||
:Consumer a owl:Class ;
|
||||
rdfs:label "Consumer" ;
|
||||
rdfs:comment "Individuals and segments interacting with brands through retail channels" .
|
||||
|
||||
:Brand a owl:Class ;
|
||||
rdfs:label "Brand" ;
|
||||
rdfs:comment "Product brands seeking to connect with consumers through retail experiences" .
|
||||
|
||||
:Retail a owl:Class ;
|
||||
rdfs:label "Retail" ;
|
||||
rdfs:comment "Channels, touchpoints, and experiences where brands meet consumers" .
|
||||
|
||||
:Agent a owl:Class ;
|
||||
rdfs:label "Agent" ;
|
||||
rdfs:comment "AI agents that orchestrate personalized brand-consumer connections" .
|
||||
|
||||
# =============================================================================
|
||||
# Datatype Properties - Consumer
|
||||
# =============================================================================
|
||||
|
||||
:segment a owl:DatatypeProperty ;
|
||||
rdfs:label "segment" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:preferences a owl:DatatypeProperty ;
|
||||
rdfs:label "preferences" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:journeyStage a owl:DatatypeProperty ;
|
||||
rdfs:label "journey stage" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:lifetimeValue a owl:DatatypeProperty ;
|
||||
rdfs:label "lifetime value" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range xsd:decimal .
|
||||
|
||||
:sentiment a owl:DatatypeProperty ;
|
||||
rdfs:label "sentiment" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range xsd:decimal .
|
||||
|
||||
:size a owl:DatatypeProperty ;
|
||||
rdfs:label "size" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:avgSpend a owl:DatatypeProperty ;
|
||||
rdfs:label "average spend" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:loyalty a owl:DatatypeProperty ;
|
||||
rdfs:label "loyalty" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range xsd:decimal .
|
||||
|
||||
# =============================================================================
|
||||
# Datatype Properties - Brand
|
||||
# =============================================================================
|
||||
|
||||
:identity a owl:DatatypeProperty ;
|
||||
rdfs:label "identity" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:positioning a owl:DatatypeProperty ;
|
||||
rdfs:label "positioning" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:campaigns a owl:DatatypeProperty ;
|
||||
rdfs:label "campaigns" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range xsd:integer .
|
||||
|
||||
:products a owl:DatatypeProperty ;
|
||||
rdfs:label "products" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:partnerships a owl:DatatypeProperty ;
|
||||
rdfs:label "partnerships" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:category a owl:DatatypeProperty ;
|
||||
rdfs:label "category" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
# =============================================================================
|
||||
# Datatype Properties - Retail
|
||||
# =============================================================================
|
||||
|
||||
:channel a owl:DatatypeProperty ;
|
||||
rdfs:label "channel" ;
|
||||
rdfs:domain :Retail ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:location a owl:DatatypeProperty ;
|
||||
rdfs:label "location" ;
|
||||
rdfs:domain :Retail ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:traffic a owl:DatatypeProperty ;
|
||||
rdfs:label "traffic" ;
|
||||
rdfs:domain :Retail ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:conversionRate a owl:DatatypeProperty ;
|
||||
rdfs:label "conversion rate" ;
|
||||
rdfs:domain :Retail ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:experienceScore a owl:DatatypeProperty ;
|
||||
rdfs:label "experience score" ;
|
||||
rdfs:domain :Retail ;
|
||||
rdfs:range xsd:decimal .
|
||||
|
||||
# =============================================================================
|
||||
# Datatype Properties - Agent
|
||||
# =============================================================================
|
||||
|
||||
:capability a owl:DatatypeProperty ;
|
||||
rdfs:label "capability" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:contextSources a owl:DatatypeProperty ;
|
||||
rdfs:label "context sources" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:accuracy a owl:DatatypeProperty ;
|
||||
rdfs:label "accuracy" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:latency a owl:DatatypeProperty ;
|
||||
rdfs:label "latency" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
:decisionsPerDay a owl:DatatypeProperty ;
|
||||
rdfs:label "decisions per day" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
# =============================================================================
|
||||
# Object Properties - Consumer <-> Brand
|
||||
# =============================================================================
|
||||
|
||||
:hasAffinityFor a owl:ObjectProperty ;
|
||||
rdfs:label "has affinity for" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range :Brand .
|
||||
|
||||
:frequents a owl:ObjectProperty ;
|
||||
rdfs:label "frequents" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range :Brand .
|
||||
|
||||
:purchasesFrom a owl:ObjectProperty ;
|
||||
rdfs:label "purchases from" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range :Brand .
|
||||
|
||||
:advocatesFor a owl:ObjectProperty ;
|
||||
rdfs:label "advocates for" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range :Brand .
|
||||
|
||||
:loyalTo a owl:ObjectProperty ;
|
||||
rdfs:label "loyal to" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range :Brand .
|
||||
|
||||
# =============================================================================
|
||||
# Object Properties - Consumer <-> Retail
|
||||
# =============================================================================
|
||||
|
||||
:shopsVia a owl:ObjectProperty ;
|
||||
rdfs:label "shops via" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
:discoversThrough a owl:ObjectProperty ;
|
||||
rdfs:label "discovers through" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
:experiences a owl:ObjectProperty ;
|
||||
rdfs:label "experiences" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
:memberOf a owl:ObjectProperty ;
|
||||
rdfs:label "member of" ;
|
||||
rdfs:domain :Consumer ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
# =============================================================================
|
||||
# Object Properties - Brand <-> Retail
|
||||
# =============================================================================
|
||||
|
||||
:merchandisesIn a owl:ObjectProperty ;
|
||||
rdfs:label "merchandises in" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
:activatesVia a owl:ObjectProperty ;
|
||||
rdfs:label "activates via" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
:promotesOn a owl:ObjectProperty ;
|
||||
rdfs:label "promotes on" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
:sellsThrough a owl:ObjectProperty ;
|
||||
rdfs:label "sells through" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
:rewardsVia a owl:ObjectProperty ;
|
||||
rdfs:label "rewards via" ;
|
||||
rdfs:domain :Brand ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
# =============================================================================
|
||||
# Object Properties - Agent <-> Consumer
|
||||
# =============================================================================
|
||||
|
||||
:recommendsTo a owl:ObjectProperty ;
|
||||
rdfs:label "recommends to" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Consumer .
|
||||
|
||||
:personalizesFor a owl:ObjectProperty ;
|
||||
rdfs:label "personalizes for" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Consumer .
|
||||
|
||||
:monitorsSentimentOf a owl:ObjectProperty ;
|
||||
rdfs:label "monitors sentiment of" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Consumer .
|
||||
|
||||
:optimizesJourneyFor a owl:ObjectProperty ;
|
||||
rdfs:label "optimizes journey for" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Consumer .
|
||||
|
||||
# =============================================================================
|
||||
# Object Properties - Agent <-> Brand
|
||||
# =============================================================================
|
||||
|
||||
:orchestratesCampaignFor a owl:ObjectProperty ;
|
||||
rdfs:label "orchestrates campaign for" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Brand .
|
||||
|
||||
:analyzesPerceptionOf a owl:ObjectProperty ;
|
||||
rdfs:label "analyzes perception of" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Brand .
|
||||
|
||||
:curatesProductsFor a owl:ObjectProperty ;
|
||||
rdfs:label "curates products for" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Brand .
|
||||
|
||||
# =============================================================================
|
||||
# Object Properties - Agent <-> Retail
|
||||
# =============================================================================
|
||||
|
||||
:tailorsExperienceAt a owl:ObjectProperty ;
|
||||
rdfs:label "tailors experience at" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
:deploysCampaignAt a owl:ObjectProperty ;
|
||||
rdfs:label "deploys campaign at" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Retail .
|
||||
|
||||
:optimizesFlowAt a owl:ObjectProperty ;
|
||||
rdfs:label "optimizes flow at" ;
|
||||
rdfs:domain :Agent ;
|
||||
rdfs:range :Retail .
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { TabKey, DomainKey, Entity } from "./types";
|
||||
import { Header, StatusBar, Toaster } from "./components";
|
||||
import { GraphView, QueryView, ExplainView, DataView, OntologyView } from "./pages";
|
||||
import { useGraphData, toast } from "./state";
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("graph");
|
||||
const [activeFilter, setActiveFilter] = useState<DomainKey | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Entity | null>(null);
|
||||
const { entities, isLoading } = useGraphData();
|
||||
|
||||
// Notification when graph loads
|
||||
useEffect(() => {
|
||||
if (!isLoading && entities.length > 0) {
|
||||
toast.success(`Graph loaded: ${entities.length} entities`);
|
||||
}
|
||||
}, [isLoading, entities.length]);
|
||||
|
||||
const handleTabChange = (tab: TabKey) => {
|
||||
setActiveTab(tab);
|
||||
if (tab !== "graph") {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", minHeight: "100vh", background: "#0A0A0F",
|
||||
fontFamily: "'IBM Plex Sans', -apple-system, sans-serif",
|
||||
color: "#E5E5E5", overflow: "hidden",
|
||||
}}>
|
||||
<Header activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
|
||||
{activeTab === "graph" && (
|
||||
<GraphView
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={setActiveFilter}
|
||||
selectedNode={selectedNode}
|
||||
onNodeSelect={setSelectedNode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "query" && <QueryView />}
|
||||
|
||||
{activeTab === "explain" && <ExplainView />}
|
||||
|
||||
{activeTab === "data" && <DataView />}
|
||||
|
||||
{activeTab === "ontology" && <OntologyView />}
|
||||
|
||||
<StatusBar />
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4 KiB |
|
|
@ -1,39 +0,0 @@
|
|||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
color: string;
|
||||
size?: "small" | "medium";
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
color,
|
||||
size = "medium",
|
||||
selected = false,
|
||||
onClick,
|
||||
}: BadgeProps) {
|
||||
const isSmall = size === "small";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: isSmall ? "3px 8px" : "6px 12px",
|
||||
borderRadius: isSmall ? 4 : 6,
|
||||
border: `1px solid ${selected ? color : color + (isSmall ? "22" : "44")}`,
|
||||
background: selected ? `${color}35` : `${color}${isSmall ? "10" : "15"}`,
|
||||
color: isSmall ? color + "cc" : color,
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
fontSize: isSmall ? 10 : 11,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
boxShadow: selected ? `0 0 8px ${color}44` : "none",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { surface, border } from "../../theme";
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
padding?: number | string;
|
||||
borderRadius?: number;
|
||||
borderColor?: string;
|
||||
onClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
padding = 24,
|
||||
borderRadius = 12,
|
||||
borderColor = border.subtle,
|
||||
onClick,
|
||||
style,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding,
|
||||
borderRadius,
|
||||
background: surface.card,
|
||||
border: `1px solid ${borderColor}`,
|
||||
cursor: onClick ? "pointer" : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { FilterButton } from "./FilterButton";
|
||||
import { text, border } from "../../theme";
|
||||
|
||||
export interface FilterItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface FilterBarProps {
|
||||
items: FilterItem[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
stats?: string;
|
||||
showAll?: boolean;
|
||||
allLabel?: string;
|
||||
emptyMessage?: string;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
items,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
stats,
|
||||
showAll = true,
|
||||
allLabel = "All",
|
||||
emptyMessage,
|
||||
maxItems = 10,
|
||||
}: FilterBarProps) {
|
||||
const displayItems = items.slice(0, maxItems);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: "12px 28px",
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
borderBottom: `1px solid ${border.subtle}`,
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<span style={{ fontSize: 11, color: text.disabled, fontFamily: "'IBM Plex Mono', monospace", marginRight: 8 }}>
|
||||
FILTER:
|
||||
</span>
|
||||
|
||||
{emptyMessage && items.length === 0 ? (
|
||||
<span style={{ fontSize: 11, color: text.disabled, fontStyle: "italic" }}>{emptyMessage}</span>
|
||||
) : (
|
||||
<>
|
||||
{showAll && (
|
||||
<FilterButton
|
||||
label={allLabel}
|
||||
isActive={!selectedKey}
|
||||
onClick={() => onSelect(null)}
|
||||
/>
|
||||
)}
|
||||
{displayItems.map((item) => (
|
||||
<FilterButton
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
color={item.color}
|
||||
isActive={selectedKey === item.key}
|
||||
onClick={() => onSelect(selectedKey === item.key ? null : item.key)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div style={{ marginLeft: "auto", fontSize: 11, color: text.hint, fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{stats}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { text, border } from "../../theme";
|
||||
|
||||
interface FilterButtonProps {
|
||||
label: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FilterButton({ label, icon, color, isActive, onClick }: FilterButtonProps) {
|
||||
const activeColor = color || "#fff";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: "5px 12px",
|
||||
borderRadius: 20,
|
||||
border: `1px solid ${isActive ? activeColor + "88" : border.medium}`,
|
||||
background: isActive ? activeColor + "15" : "transparent",
|
||||
color: isActive ? activeColor : text.subtle,
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}
|
||||
>
|
||||
{icon && <>{icon} </>}{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import type { TabKey } from "../../types";
|
||||
|
||||
interface HeaderProps {
|
||||
activeTab: TabKey;
|
||||
onTabChange: (tab: TabKey) => void;
|
||||
}
|
||||
|
||||
export function Header({ activeTab, onTabChange }: HeaderProps) {
|
||||
return (
|
||||
<div style={{
|
||||
borderBottom: "1px solid rgba(255,255,255,0.06)",
|
||||
padding: "16px 28px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
background: "linear-gradient(180deg, rgba(15,15,22,1) 0%, rgba(10,10,15,1) 100%)",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<img
|
||||
src="/tg.svg"
|
||||
alt="TrustGraph"
|
||||
style={{ width: 36, height: 36, borderRadius: 8 }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, letterSpacing: "-0.02em", color: "#fff" }}>
|
||||
TrustGraph
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#666", fontFamily: "'IBM Plex Mono', monospace", letterSpacing: "0.05em" }}>
|
||||
CONTEXT GRAPH DEMO
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, fontFamily: "'IBM Plex Mono', monospace", fontSize: 12 }}>
|
||||
{(["graph", "query", "explain", "data", "ontology"] as const).map((tab) => {
|
||||
const labels: Record<typeof tab, string> = {
|
||||
graph: "◈ Context Graph",
|
||||
query: "⚡ Agent Query",
|
||||
explain: "◉ Explain",
|
||||
data: "▤ Table Explorer",
|
||||
ontology: "◇ Ontology",
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => onTabChange(tab)}
|
||||
style={{
|
||||
padding: "7px 16px", borderRadius: 6, border: "none", cursor: "pointer",
|
||||
background: activeTab === tab ? "rgba(255,255,255,0.1)" : "transparent",
|
||||
color: activeTab === tab ? "#fff" : "#666",
|
||||
fontFamily: "'IBM Plex Mono', monospace", fontSize: 12, fontWeight: activeTab === tab ? 600 : 400,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{labels[tab]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { semantic, text } from "../../theme";
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
variant?: "loading" | "error";
|
||||
}
|
||||
|
||||
export function LoadingState({ message, variant = "loading" }: LoadingStateProps) {
|
||||
const isError = variant === "error";
|
||||
const defaultMessage = isError ? "Error loading data" : "Loading...";
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: isError ? semantic.error : text.faint,
|
||||
}}>
|
||||
{message || defaultMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { semantic, text, surface, border, withGlow } from "../../theme";
|
||||
|
||||
export interface Message {
|
||||
role: string;
|
||||
text: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isUser = message.role === "human";
|
||||
const messageType = message.type;
|
||||
|
||||
const getTypeStyles = () => {
|
||||
switch (messageType) {
|
||||
case "thinking":
|
||||
return {
|
||||
bg: withGlow(semantic.thinking, 0.08),
|
||||
border: withGlow(semantic.thinking, 0.2),
|
||||
icon: "◈",
|
||||
label: "THINKING",
|
||||
color: semantic.thinking,
|
||||
};
|
||||
case "observation":
|
||||
return {
|
||||
bg: withGlow(semantic.observation, 0.08),
|
||||
border: withGlow(semantic.observation, 0.2),
|
||||
icon: "◉",
|
||||
label: "OBSERVATION",
|
||||
color: semantic.observation,
|
||||
};
|
||||
case "answer":
|
||||
return {
|
||||
bg: withGlow(semantic.answer, 0.08),
|
||||
border: withGlow(semantic.answer, 0.2),
|
||||
icon: "✓",
|
||||
label: "ANSWER",
|
||||
color: semantic.answer,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const typeStyles = getTypeStyles();
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 10,
|
||||
background: withGlow(semantic.user, 0.08),
|
||||
border: `1px solid ${withGlow(semantic.user, 0.2)}`,
|
||||
alignSelf: "flex-end",
|
||||
maxWidth: "80%",
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: withGlow(semantic.user, 0.53), fontFamily: "'IBM Plex Mono', monospace", marginBottom: 6 }}>
|
||||
YOU
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: text.primary, lineHeight: 1.5 }}>
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 10,
|
||||
background: typeStyles?.bg || surface.card,
|
||||
border: `1px solid ${typeStyles?.border || border.default}`,
|
||||
maxWidth: "90%",
|
||||
}}>
|
||||
{typeStyles && (
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: withGlow(typeStyles.color, 0.53),
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
marginBottom: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}>
|
||||
<span style={{ color: typeStyles.color }}>{typeStyles.icon}</span>
|
||||
{typeStyles.label}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 13, color: text.secondary, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { text, surface, border, palette } from "../../theme";
|
||||
|
||||
interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
isLoading?: boolean;
|
||||
buttonColor?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Search...",
|
||||
buttonText = "Search",
|
||||
isLoading = false,
|
||||
buttonColor = palette.blue,
|
||||
disabled = false,
|
||||
}: SearchInputProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = disabled || isLoading || !value.trim();
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px 16px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${border.medium}`,
|
||||
background: surface.card,
|
||||
color: text.primary,
|
||||
fontSize: 14,
|
||||
fontFamily: "'IBM Plex Sans', sans-serif",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
padding: "12px 20px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${buttonColor}44`,
|
||||
background: isDisabled ? surface.card : `${buttonColor}1a`,
|
||||
color: isDisabled ? text.disabled : buttonColor,
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}
|
||||
>
|
||||
{isLoading ? "..." : buttonText}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { text } from "../../theme";
|
||||
|
||||
interface SectionLabelProps {
|
||||
children: React.ReactNode;
|
||||
marginBottom?: number;
|
||||
marginTop?: number;
|
||||
}
|
||||
|
||||
export function SectionLabel({ children, marginBottom = 10, marginTop }: SectionLabelProps) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: text.disabled,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
letterSpacing: "0.1em",
|
||||
marginBottom,
|
||||
marginTop,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { useConnectionState } from "@trustgraph/react-provider";
|
||||
import { useProgressStateStore } from "@trustgraph/react-state";
|
||||
import { semantic, palette, text, border } from "../../theme";
|
||||
|
||||
export function StatusBar() {
|
||||
const connectionState = useConnectionState();
|
||||
const activity = useProgressStateStore((state) => state.activity);
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
if (!connectionState) return { color: text.subtle, text: "Initializing..." };
|
||||
switch (connectionState.status) {
|
||||
case "authenticated":
|
||||
return { color: semantic.success, text: "Authenticated" };
|
||||
case "connected":
|
||||
return { color: semantic.success, text: "Connected" };
|
||||
case "unauthenticated":
|
||||
return { color: semantic.info, text: "Connected" };
|
||||
case "connecting":
|
||||
return { color: palette.amber, text: "Connecting..." };
|
||||
case "reconnecting":
|
||||
return { color: semantic.warning, text: `Reconnecting (${connectionState.reconnectAttempt}/${connectionState.maxAttempts})...` };
|
||||
case "failed":
|
||||
return { color: semantic.error, text: "Connection failed" };
|
||||
default:
|
||||
return { color: text.subtle, text: connectionState.status };
|
||||
}
|
||||
};
|
||||
|
||||
const status = getStatusDisplay();
|
||||
const activeActivity = activity.size > 0 ? Array.from(activity)[0] : null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "fixed", bottom: 0, left: 0, right: 0,
|
||||
padding: "8px 28px", borderTop: `1px solid ${border.subtle}`,
|
||||
background: "rgba(10,10,15,0.95)", backdropFilter: "blur(8px)",
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, color: text.hint,
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{activeActivity ? (
|
||||
<>
|
||||
<span style={{ color: palette.amber }}>◌</span>
|
||||
<span style={{ color: text.faint }}>{activeActivity}...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: semantic.success }}>◈</span>
|
||||
<span style={{ color: text.disabled }}>Ready</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<span style={{ color: status.color }}>●</span> {status.text}
|
||||
<span style={{ color: text.subtle }}>|</span>
|
||||
<span>trustgraph.ai</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { useToastStore, Toast, ToastType } from "../../state/toastStore";
|
||||
import { semantic, surface, text } from "../../theme";
|
||||
|
||||
const typeStyles: Record<ToastType, { color: string; icon: string }> = {
|
||||
success: { color: semantic.success, icon: "✓" },
|
||||
error: { color: semantic.error, icon: "✕" },
|
||||
warning: { color: semantic.warning, icon: "!" },
|
||||
info: { color: semantic.info, icon: "i" },
|
||||
};
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
|
||||
const style = typeStyles[toast.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 16px",
|
||||
background: surface.overlay,
|
||||
borderRadius: 8,
|
||||
borderLeft: `3px solid ${style.color}`,
|
||||
backdropFilter: "blur(12px)",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
|
||||
minWidth: 280,
|
||||
maxWidth: 400,
|
||||
animation: "slideIn 0.2s ease-out",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: style.color,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
width: 18,
|
||||
height: 18,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
border: `1px solid ${style.color}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{style.icon}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: text.secondary,
|
||||
fontFamily: "'IBM Plex Sans', sans-serif",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: text.faint,
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
padding: 4,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const toasts = useToastStore((state) => state.toasts);
|
||||
const removeToast = useToastStore((state) => state.removeToast);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 60,
|
||||
left: 28,
|
||||
zIndex: 1000,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onDismiss={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface TypewriterProps {
|
||||
text: string;
|
||||
speed?: number;
|
||||
onDone?: () => void;
|
||||
}
|
||||
|
||||
export function Typewriter({ text, speed = 12, onDone }: TypewriterProps) {
|
||||
const [displayed, setDisplayed] = useState("");
|
||||
const idx = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
idx.current = 0;
|
||||
setDisplayed("");
|
||||
const interval = setInterval(() => {
|
||||
idx.current++;
|
||||
if (idx.current >= text.length) {
|
||||
setDisplayed(text);
|
||||
clearInterval(interval);
|
||||
onDone?.();
|
||||
} else {
|
||||
setDisplayed(text.slice(0, idx.current));
|
||||
}
|
||||
}, speed);
|
||||
return () => clearInterval(interval);
|
||||
}, [text, speed, onDone]);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{displayed}
|
||||
<span style={{ opacity: displayed.length < text.length ? 1 : 0, color: "#FCD34D" }}>
|
||||
▌
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
export { SectionLabel } from "./SectionLabel";
|
||||
export { FilterButton } from "./FilterButton";
|
||||
export { Header } from "./Header";
|
||||
export { StatusBar } from "./StatusBar";
|
||||
export { Typewriter } from "./Typewriter";
|
||||
export { Card } from "./Card";
|
||||
export { Badge } from "./Badge";
|
||||
export { LoadingState } from "./LoadingState";
|
||||
export { Toaster } from "./Toaster";
|
||||
export { SearchInput } from "./SearchInput";
|
||||
export { FilterBar } from "./FilterBar";
|
||||
export type { FilterItem } from "./FilterBar";
|
||||
export { MessageBubble } from "./MessageBubble";
|
||||
export type { Message } from "./MessageBubble";
|
||||
|
|
@ -1,451 +0,0 @@
|
|||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { ZoomControls } from "./ZoomControls";
|
||||
import { border, palette, text, withGlow } from "../../theme";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExplainGraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ExplainGraphEdge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
label: string;
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
interface LayoutNode extends ExplainGraphNode {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
interface ExplainGraphProps {
|
||||
nodes: ExplainGraphNode[];
|
||||
edges: ExplainGraphEdge[];
|
||||
highlightedNodeIds: string[];
|
||||
highlightedEdgeIds: string[];
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
onEdgeClick?: (edgeId: string) => void;
|
||||
}
|
||||
|
||||
// ── Simple force layout ─────────────────────────────────────────────
|
||||
|
||||
function computeLayout(
|
||||
nodes: ExplainGraphNode[],
|
||||
edges: ExplainGraphEdge[],
|
||||
width: number,
|
||||
height: number,
|
||||
): LayoutNode[] {
|
||||
if (nodes.length === 0 || width === 0) return [];
|
||||
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
|
||||
// Initial positions: circle layout
|
||||
const layoutNodes: LayoutNode[] = nodes.map((n, i) => {
|
||||
const angle = (Math.PI * 2 * i) / nodes.length - Math.PI / 2;
|
||||
const radius = Math.min(cx, cy) * 0.55;
|
||||
return {
|
||||
...n,
|
||||
x: cx + Math.cos(angle) * radius,
|
||||
y: cy + Math.sin(angle) * radius,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Run simple force simulation
|
||||
const iterations = 120;
|
||||
const repulsion = 2000;
|
||||
const attraction = 0.005;
|
||||
const damping = 0.85;
|
||||
const centerPull = 0.01;
|
||||
|
||||
const nodeMap = new Map(layoutNodes.map((n, i) => [n.id, i]));
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
// Repulsion between all pairs
|
||||
for (let i = 0; i < layoutNodes.length; i++) {
|
||||
for (let j = i + 1; j < layoutNodes.length; j++) {
|
||||
const a = layoutNodes[i];
|
||||
const b = layoutNodes[j];
|
||||
let dx = a.x - b.x;
|
||||
let dy = a.y - b.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const force = repulsion / (dist * dist);
|
||||
dx = (dx / dist) * force;
|
||||
dy = (dy / dist) * force;
|
||||
a.vx += dx;
|
||||
a.vy += dy;
|
||||
b.vx -= dx;
|
||||
b.vy -= dy;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
for (const edge of edges) {
|
||||
const ai = nodeMap.get(edge.from);
|
||||
const bi = nodeMap.get(edge.to);
|
||||
if (ai === undefined || bi === undefined) continue;
|
||||
const a = layoutNodes[ai];
|
||||
const b = layoutNodes[bi];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const fx = dx * attraction;
|
||||
const fy = dy * attraction;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
// Center pull
|
||||
for (const n of layoutNodes) {
|
||||
n.vx += (cx - n.x) * centerPull;
|
||||
n.vy += (cy - n.y) * centerPull;
|
||||
}
|
||||
|
||||
// Apply velocity
|
||||
for (const n of layoutNodes) {
|
||||
n.vx *= damping;
|
||||
n.vy *= damping;
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
|
||||
// Keep in bounds with padding
|
||||
const pad = 40;
|
||||
n.x = Math.max(pad, Math.min(width - pad, n.x));
|
||||
n.y = Math.max(pad, Math.min(height - pad, n.y));
|
||||
}
|
||||
}
|
||||
|
||||
return layoutNodes;
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────
|
||||
|
||||
export function ExplainGraph({
|
||||
nodes,
|
||||
edges,
|
||||
highlightedNodeIds,
|
||||
highlightedEdgeIds,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
}: ExplainGraphProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
|
||||
// Zoom and pan
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const isPanningRef = useRef(false);
|
||||
const lastPanPosRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Track container size
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setContainerSize({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||
}
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Layout
|
||||
const layoutNodes = useMemo(
|
||||
() => computeLayout(nodes, edges, containerSize.width, containerSize.height),
|
||||
[nodes, edges, containerSize],
|
||||
);
|
||||
|
||||
const nodeMap = useMemo(
|
||||
() => new Map(layoutNodes.map(n => [n.id, n])),
|
||||
[layoutNodes],
|
||||
);
|
||||
|
||||
// Grid lines
|
||||
const gridLines = useMemo(() => {
|
||||
const lines: React.ReactElement[] = [];
|
||||
const { width, height } = containerSize;
|
||||
if (width === 0) return lines;
|
||||
for (let x = 0; x < width; x += 30) {
|
||||
lines.push(<line key={`v-${x}`} x1={x} y1={0} x2={x} y2={height} stroke={border.grid} strokeWidth={0.5} />);
|
||||
}
|
||||
for (let y = 0; y < height; y += 30) {
|
||||
lines.push(<line key={`h-${y}`} x1={0} y1={y} x2={width} y2={y} stroke={border.grid} strokeWidth={0.5} />);
|
||||
}
|
||||
return lines;
|
||||
}, [containerSize]);
|
||||
|
||||
// Zoom
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.min(4, Math.max(0.25, zoom * delta));
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const cursorX = e.clientX - rect.left;
|
||||
const cursorY = e.clientY - rect.top;
|
||||
const zoomRatio = newZoom / zoom;
|
||||
setPan(p => ({ x: cursorX - (cursorX - p.x) * zoomRatio, y: cursorY - (cursorY - p.y) * zoomRatio }));
|
||||
setZoom(newZoom);
|
||||
}, [zoom]);
|
||||
|
||||
// Pan
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
isPanningRef.current = true;
|
||||
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isPanningRef.current) return;
|
||||
const dx = e.clientX - lastPanPosRef.current.x;
|
||||
const dy = e.clientY - lastPanPosRef.current.y;
|
||||
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
setPan(p => ({ x: p.x + dx, y: p.y + dy }));
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => { isPanningRef.current = false; }, []);
|
||||
|
||||
const handleResetView = useCallback(() => { setZoom(1); setPan({ x: 0, y: 0 }); }, []);
|
||||
|
||||
const hasHighlights = highlightedNodeIds.length > 0 || highlightedEdgeIds.length > 0;
|
||||
const NODE_R = 10;
|
||||
|
||||
if (containerSize.width === 0) {
|
||||
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={containerSize.width}
|
||||
height={containerSize.height}
|
||||
style={{ display: "block", background: "transparent", cursor: isPanningRef.current ? "grabbing" : "default" }}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<g>{gridLines}</g>
|
||||
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||||
{/* Edges */}
|
||||
{edges.map((edge) => {
|
||||
const from = nodeMap.get(edge.from);
|
||||
const to = nodeMap.get(edge.to);
|
||||
if (!from || !to) return null;
|
||||
|
||||
const isHighlighted = highlightedEdgeIds.includes(edge.id);
|
||||
const isDimmed = hasHighlights && !isHighlighted;
|
||||
const isEdgeHovered = hovered === `edge-${edge.id}`;
|
||||
|
||||
const mx = (from.x + to.x) / 2 + (from.y - to.y) * 0.15;
|
||||
const my = (from.y + to.y) / 2 + (to.x - from.x) * 0.15;
|
||||
const path = `M ${from.x} ${from.y} Q ${mx} ${my} ${to.x} ${to.y}`;
|
||||
|
||||
const alpha = isDimmed ? 0.15 : isHighlighted || isEdgeHovered ? 0.8 : 0.35;
|
||||
const edgeColor = palette.cyan;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={edge.id}
|
||||
style={{ cursor: onEdgeClick ? "pointer" : "default" }}
|
||||
onClick={() => onEdgeClick?.(edge.id)}
|
||||
onMouseEnter={() => setHovered(`edge-${edge.id}`)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{/* Wider invisible hit area */}
|
||||
<path d={path} stroke="transparent" strokeWidth={12} fill="none" />
|
||||
<path
|
||||
d={path}
|
||||
stroke={edgeColor}
|
||||
strokeOpacity={alpha}
|
||||
strokeWidth={isHighlighted || isEdgeHovered ? 2 : 1}
|
||||
fill="none"
|
||||
/>
|
||||
{/* Edge label */}
|
||||
<text
|
||||
x={mx}
|
||||
y={my - 6}
|
||||
fill={`rgba(255,255,255,${isDimmed ? 0.15 : isHighlighted || isEdgeHovered ? 0.9 : 0.5})`}
|
||||
fontSize={8}
|
||||
fontFamily="'IBM Plex Mono', monospace"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{edge.label}
|
||||
</text>
|
||||
{/* Arrowhead */}
|
||||
{(() => {
|
||||
// Point on curve near the end (t=0.85)
|
||||
const t = 0.85;
|
||||
const ax = (1 - t) * (1 - t) * from.x + 2 * (1 - t) * t * mx + t * t * to.x;
|
||||
const ay = (1 - t) * (1 - t) * from.y + 2 * (1 - t) * t * my + t * t * to.y;
|
||||
const dx = to.x - ax;
|
||||
const dy = to.y - ay;
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
// Arrow tip at edge of node circle
|
||||
const tipX = to.x - ux * NODE_R;
|
||||
const tipY = to.y - uy * NODE_R;
|
||||
const arrowSize = 5;
|
||||
const p1x = tipX - ux * arrowSize + uy * arrowSize * 0.4;
|
||||
const p1y = tipY - uy * arrowSize - ux * arrowSize * 0.4;
|
||||
const p2x = tipX - ux * arrowSize - uy * arrowSize * 0.4;
|
||||
const p2y = tipY - uy * arrowSize + ux * arrowSize * 0.4;
|
||||
return (
|
||||
<polygon
|
||||
points={`${tipX},${tipY} ${p1x},${p1y} ${p2x},${p2y}`}
|
||||
fill={edgeColor}
|
||||
fillOpacity={alpha}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nodes */}
|
||||
{layoutNodes.map((node) => {
|
||||
const isHighlighted = highlightedNodeIds.includes(node.id);
|
||||
const isHovered = hovered === node.id;
|
||||
const isDimmed = hasHighlights && !isHighlighted;
|
||||
const nodeColor = node.color || palette.blue;
|
||||
const alpha = isDimmed ? 0.25 : 1;
|
||||
const r = isHighlighted || isHovered ? NODE_R * 1.3 : NODE_R;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
style={{ cursor: onNodeClick ? "pointer" : "default" }}
|
||||
onClick={() => onNodeClick?.(node.id)}
|
||||
onMouseEnter={() => setHovered(node.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{/* Glow */}
|
||||
{(isHighlighted || isHovered) && (
|
||||
<circle cx={node.x} cy={node.y} r={r + 8} fill={nodeColor} fillOpacity={0.15} />
|
||||
)}
|
||||
{/* Circle */}
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={r}
|
||||
fill={nodeColor}
|
||||
fillOpacity={alpha * 0.2}
|
||||
stroke={nodeColor}
|
||||
strokeOpacity={alpha}
|
||||
strokeWidth={isHighlighted ? 1.5 : 0.75}
|
||||
/>
|
||||
{/* Label */}
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + r + 12}
|
||||
fill={`rgba(255,255,255,${alpha * (isHighlighted ? 1 : 0.7)})`}
|
||||
fontSize={isHovered ? 9 : 8}
|
||||
fontWeight={isHighlighted ? "bold" : "normal"}
|
||||
fontFamily="'IBM Plex Sans', sans-serif"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<ZoomControls
|
||||
zoom={zoom}
|
||||
onZoomIn={() => setZoom(z => Math.min(4, z * 1.2))}
|
||||
onZoomOut={() => setZoom(z => Math.max(0.25, z / 1.2))}
|
||||
onReset={handleResetView}
|
||||
/>
|
||||
|
||||
{/* Empty state */}
|
||||
{nodes.length === 0 && (
|
||||
<div style={{
|
||||
position: "absolute", inset: 0,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: text.hint, fontSize: 13, fontStyle: "italic",
|
||||
pointerEvents: "none",
|
||||
}}>
|
||||
Graph will populate as explain events arrive
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{hovered && !hovered.startsWith("edge-") && (() => {
|
||||
const node = nodeMap.get(hovered);
|
||||
if (!node) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: node.x * zoom + pan.x + 20,
|
||||
top: node.y * zoom + pan.y - 20,
|
||||
background: "rgba(15,15,20,0.95)",
|
||||
border: `1px solid ${withGlow(node.color || palette.blue, 0.3)}`,
|
||||
borderRadius: 8, padding: "8px 12px",
|
||||
pointerEvents: "none", backdropFilter: "blur(12px)", zIndex: 10,
|
||||
}}>
|
||||
<div style={{ color: node.color || palette.blue, fontWeight: 700, fontSize: 12, fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{node.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Edge tooltip */}
|
||||
{hovered?.startsWith("edge-") && (() => {
|
||||
const edgeId = hovered.slice(5);
|
||||
const edge = edges.find(e => e.id === edgeId);
|
||||
if (!edge) return null;
|
||||
const from = nodeMap.get(edge.from);
|
||||
const to = nodeMap.get(edge.to);
|
||||
if (!from || !to) return null;
|
||||
const mx = ((from.x + to.x) / 2) * zoom + pan.x;
|
||||
const my = ((from.y + to.y) / 2) * zoom + pan.y;
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute", left: mx + 15, top: my - 15,
|
||||
background: "rgba(15,15,20,0.95)",
|
||||
border: `1px solid ${withGlow(palette.cyan, 0.3)}`,
|
||||
borderRadius: 8, padding: "8px 12px",
|
||||
pointerEvents: "none", backdropFilter: "blur(12px)", zIndex: 10,
|
||||
maxWidth: 280,
|
||||
}}>
|
||||
<div style={{ color: palette.cyan, fontWeight: 700, fontSize: 11, fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{edge.label}
|
||||
</div>
|
||||
{edge.reasoning && (
|
||||
<div style={{ color: text.muted, fontSize: 10, marginTop: 4, lineHeight: 1.4, fontStyle: "italic" }}>
|
||||
{edge.reasoning.length > 150 ? edge.reasoning.slice(0, 150) + "..." : edge.reasoning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,596 +0,0 @@
|
|||
import { useEffect, useRef, useCallback, useState, MouseEvent } from "react";
|
||||
import type { DomainKey, Entity, GraphNode, OntologyType, Relationship } from "../../types";
|
||||
import { ZoomControls } from "./ZoomControls";
|
||||
import { border } from "../../theme";
|
||||
|
||||
interface GraphCanvasProps {
|
||||
entities: Entity[];
|
||||
relationships: Relationship[];
|
||||
ontology: OntologyType;
|
||||
highlightedEntities: string[];
|
||||
onNodeClick: (node: GraphNode) => void;
|
||||
activeFilter: DomainKey | null;
|
||||
}
|
||||
|
||||
const SETTLE_TIME = 10000; // 10 seconds until nodes settle
|
||||
const FRAME_INTERVAL = 1000 / 30; // 30fps
|
||||
|
||||
export function GraphCanvas({ entities, relationships, ontology, highlightedEntities, onNodeClick, activeFilter }: GraphCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const staticCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const nodesCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const edgesCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const nodesRef = useRef<GraphNode[]>([]);
|
||||
const animRef = useRef<number>(0);
|
||||
const hoveredRef = useRef<string | null>(null);
|
||||
const settledRef = useRef<boolean>(false);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const timeRef = useRef<number>(0);
|
||||
const lastFrameTimeRef = useRef<number>(0);
|
||||
|
||||
// Store view state in refs to avoid triggering resets
|
||||
const highlightedRef = useRef<string[]>(highlightedEntities);
|
||||
const activeFilterRef = useRef<DomainKey | null>(activeFilter);
|
||||
const relationshipsRef = useRef<Relationship[]>(relationships);
|
||||
const ontologyRef = useRef<OntologyType>(ontology);
|
||||
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
// Zoom and pan state
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const zoomRef = useRef(1);
|
||||
const panRef = useRef({ x: 0, y: 0 });
|
||||
const isPanningRef = useRef(false);
|
||||
const lastPanPosRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Keep zoom/pan refs in sync
|
||||
zoomRef.current = zoom;
|
||||
panRef.current = pan;
|
||||
|
||||
// Keep refs in sync with props
|
||||
useEffect(() => {
|
||||
highlightedRef.current = highlightedEntities;
|
||||
}, [highlightedEntities]);
|
||||
|
||||
useEffect(() => {
|
||||
activeFilterRef.current = activeFilter;
|
||||
}, [activeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
relationshipsRef.current = relationships;
|
||||
}, [relationships]);
|
||||
|
||||
useEffect(() => {
|
||||
ontologyRef.current = ontology;
|
||||
}, [ontology]);
|
||||
|
||||
// Track container size changes
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setContainerSize({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Draw static layer (grid + domain labels)
|
||||
const drawStaticLayer = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, domainPositions: Record<DomainKey, { x: number; y: number }>) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Grid stays fixed (no transform)
|
||||
ctx.strokeStyle = border.grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x < canvas.width; x += 60) {
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < canvas.height; y += 60) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Domain labels with zoom/pan transform
|
||||
ctx.save();
|
||||
ctx.translate(panRef.current.x, panRef.current.y);
|
||||
ctx.scale(zoomRef.current, zoomRef.current);
|
||||
|
||||
const currentOntology = ontologyRef.current;
|
||||
(Object.entries(domainPositions) as [DomainKey, { x: number; y: number }][]).forEach(([domain, pos]) => {
|
||||
const data = currentOntology[domain];
|
||||
ctx.font = "bold 22px 'IBM Plex Mono', monospace";
|
||||
ctx.fillStyle = data.color + "44";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(data.label.toUpperCase(), pos.x, pos.y - Math.min(canvas.width, canvas.height) * 0.14);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}, []);
|
||||
|
||||
// Draw nodes layer - reads from refs
|
||||
const drawNodesLayer = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, time: number) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Apply zoom/pan transform
|
||||
ctx.save();
|
||||
ctx.translate(panRef.current.x, panRef.current.y);
|
||||
ctx.scale(zoomRef.current, zoomRef.current);
|
||||
|
||||
const nodes = nodesRef.current;
|
||||
const settled = settledRef.current;
|
||||
const highlighted = highlightedRef.current;
|
||||
const filter = activeFilterRef.current;
|
||||
const rels = relationshipsRef.current;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const isHighlighted = highlighted && highlighted.includes(node.id);
|
||||
const isHovered = hoveredRef.current === node.id;
|
||||
const isDimmed = highlighted && highlighted.length > 0 && !isHighlighted;
|
||||
const isFiltered = filter && node.domain !== filter && !rels.some(
|
||||
r => r.domain.includes(filter) && (r.from === node.id || r.to === node.id)
|
||||
);
|
||||
|
||||
const alpha = isFiltered ? 0.15 : isDimmed ? 0.3 : 1;
|
||||
const r = isHighlighted || isHovered ? node.r * 1.4 : node.r;
|
||||
const pulseR = isHighlighted && !settled ? Math.sin(time * 3) * 3 : 0;
|
||||
|
||||
// Glow
|
||||
if ((isHighlighted || isHovered) && !isFiltered) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, r + 12 + pulseR, 0, Math.PI * 2);
|
||||
const grd = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 12 + pulseR);
|
||||
grd.addColorStop(0, node.glow);
|
||||
grd.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color + Math.round(alpha * 255 * 0.2).toString(16).padStart(2, "0");
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = node.color + Math.round(alpha * 255).toString(16).padStart(2, "0");
|
||||
ctx.lineWidth = isHighlighted ? 2.5 : 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
ctx.font = `${isHighlighted ? "bold " : ""}${isHovered ? 17 : 14}px 'IBM Plex Sans', sans-serif`;
|
||||
ctx.fillStyle = `rgba(255,255,255,${alpha * (isHighlighted ? 1 : 0.75)})`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(node.label, node.x, node.y + r + 18);
|
||||
|
||||
// Update node positions (spring physics + drift) - only if not settled
|
||||
if (!settled) {
|
||||
node.x += (node.targetX - node.x) * 0.02;
|
||||
node.y += (node.targetY - node.y) * 0.02;
|
||||
node.x += Math.sin(time + node.targetX * 0.01) * 0.3;
|
||||
node.y += Math.cos(time + node.targetY * 0.01) * 0.3;
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}, []);
|
||||
|
||||
// Draw edges layer - reads from refs
|
||||
const drawEdgesLayer = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, time: number) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Apply zoom/pan transform
|
||||
ctx.save();
|
||||
ctx.translate(panRef.current.x, panRef.current.y);
|
||||
ctx.scale(zoomRef.current, zoomRef.current);
|
||||
|
||||
const nodes = nodesRef.current;
|
||||
const highlighted = highlightedRef.current;
|
||||
const filter = activeFilterRef.current;
|
||||
const rels = relationshipsRef.current;
|
||||
|
||||
const filteredRels = filter
|
||||
? rels.filter((r) => r.domain.includes(filter))
|
||||
: rels;
|
||||
|
||||
filteredRels.forEach((rel) => {
|
||||
const fromNode = nodes.find((n) => n.id === rel.from);
|
||||
const toNode = nodes.find((n) => n.id === rel.to);
|
||||
if (!fromNode || !toNode) return;
|
||||
|
||||
const isHighlighted =
|
||||
highlighted &&
|
||||
highlighted.includes(rel.from) &&
|
||||
highlighted.includes(rel.to);
|
||||
|
||||
const baseAlpha = isHighlighted ? 0.7 : 0.12;
|
||||
const pulse = isHighlighted ? Math.sin(time * 4) * 0.15 + 0.15 : 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(fromNode.x, fromNode.y);
|
||||
// Curved edges
|
||||
const mx = (fromNode.x + toNode.x) / 2 + (fromNode.y - toNode.y) * 0.1;
|
||||
const my = (fromNode.y + toNode.y) / 2 + (toNode.x - fromNode.x) * 0.1;
|
||||
ctx.quadraticCurveTo(mx, my, toNode.x, toNode.y);
|
||||
|
||||
const gradient = ctx.createLinearGradient(fromNode.x, fromNode.y, toNode.x, toNode.y);
|
||||
gradient.addColorStop(0, fromNode.color + Math.round((baseAlpha + pulse) * 255).toString(16).padStart(2, "0"));
|
||||
gradient.addColorStop(1, toNode.color + Math.round((baseAlpha + pulse) * 255).toString(16).padStart(2, "0"));
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.lineWidth = isHighlighted ? 3 : 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Animated particles on highlighted edges
|
||||
if (isHighlighted) {
|
||||
const t = (time * 2) % 1;
|
||||
const px = (1 - t) * (1 - t) * fromNode.x + 2 * (1 - t) * t * mx + t * t * toNode.x;
|
||||
const py = (1 - t) * (1 - t) * fromNode.y + 2 * (1 - t) * t * my + t * t * toNode.y;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}, []);
|
||||
|
||||
// Animation loop function - separate from setup
|
||||
const runAnimation = useCallback(() => {
|
||||
const nodesCanvas = nodesCanvasRef.current;
|
||||
const edgesCanvas = edgesCanvasRef.current;
|
||||
const nodesCtx = nodesCanvas?.getContext("2d");
|
||||
const edgesCtx = edgesCanvas?.getContext("2d");
|
||||
|
||||
if (!nodesCtx || !nodesCanvas || !edgesCtx || !edgesCanvas) return;
|
||||
|
||||
// Capture validated references for the closure
|
||||
const validNodesCtx = nodesCtx;
|
||||
const validNodesCanvas = nodesCanvas;
|
||||
const validEdgesCtx = edgesCtx;
|
||||
const validEdgesCanvas = edgesCanvas;
|
||||
|
||||
function animate(currentTime: number) {
|
||||
// Throttle to target fps
|
||||
if (currentTime - lastFrameTimeRef.current < FRAME_INTERVAL) {
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
lastFrameTimeRef.current = currentTime;
|
||||
timeRef.current += 0.01;
|
||||
|
||||
// Check if we should settle
|
||||
if (!settledRef.current && currentTime - startTimeRef.current > SETTLE_TIME) {
|
||||
settledRef.current = true;
|
||||
}
|
||||
|
||||
const hasHighlights = highlightedRef.current && highlightedRef.current.length > 0;
|
||||
const isSettled = settledRef.current;
|
||||
|
||||
// Draw edges layer
|
||||
drawEdgesLayer(validEdgesCtx, validEdgesCanvas, timeRef.current);
|
||||
|
||||
// Draw nodes layer
|
||||
if (!isSettled || hasHighlights || hoveredRef.current) {
|
||||
drawNodesLayer(validNodesCtx, validNodesCanvas, timeRef.current);
|
||||
}
|
||||
|
||||
// Continue animation if not settled, or if there are highlights
|
||||
if (!isSettled || hasHighlights) {
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Settled with no highlights - do one final draw and stop
|
||||
drawNodesLayer(validNodesCtx, validNodesCanvas, timeRef.current);
|
||||
drawEdgesLayer(validEdgesCtx, validEdgesCanvas, timeRef.current);
|
||||
animRef.current = 0;
|
||||
}
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
}, [drawNodesLayer, drawEdgesLayer]);
|
||||
|
||||
// Main setup - only runs when data or size changes
|
||||
useEffect(() => {
|
||||
const staticCanvas = staticCanvasRef.current;
|
||||
const nodesCanvas = nodesCanvasRef.current;
|
||||
const edgesCanvas = edgesCanvasRef.current;
|
||||
if (!staticCanvas || !nodesCanvas || !edgesCanvas || containerSize.width === 0) return;
|
||||
|
||||
// Cancel any existing animation
|
||||
if (animRef.current) {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
animRef.current = 0;
|
||||
}
|
||||
|
||||
// Setup all canvases
|
||||
[staticCanvas, nodesCanvas, edgesCanvas].forEach(canvas => {
|
||||
canvas.width = containerSize.width * 2;
|
||||
canvas.height = containerSize.height * 2;
|
||||
canvas.style.width = containerSize.width + "px";
|
||||
canvas.style.height = containerSize.height + "px";
|
||||
});
|
||||
|
||||
const cx = staticCanvas.width / 2;
|
||||
const cy = staticCanvas.height / 2;
|
||||
|
||||
// Position nodes in domain clusters
|
||||
const domainKeys = Object.keys(ontology);
|
||||
const domainPositions: Record<DomainKey, { x: number; y: number }> = {};
|
||||
domainKeys.forEach((domain, i) => {
|
||||
const angle = (Math.PI * 2 * i) / domainKeys.length - Math.PI / 2;
|
||||
const radius = Math.min(cx, cy) * 0.45;
|
||||
domainPositions[domain] = {
|
||||
x: cx + Math.cos(angle) * radius,
|
||||
y: cy + Math.sin(angle) * radius,
|
||||
};
|
||||
});
|
||||
|
||||
nodesRef.current = entities.map((e) => {
|
||||
const dp = domainPositions[e.domain];
|
||||
const subIdx = ontology[e.domain].subclasses.findIndex((s) => s.id === e.id);
|
||||
const total = ontology[e.domain].subclasses.length;
|
||||
const angle = ((Math.PI * 2) / total) * subIdx - Math.PI / 2;
|
||||
const radius = Math.min(staticCanvas.width, staticCanvas.height) * 0.1;
|
||||
return {
|
||||
...e,
|
||||
x: dp.x + Math.cos(angle) * radius,
|
||||
y: dp.y + Math.sin(angle) * radius,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
targetX: dp.x + Math.cos(angle) * radius,
|
||||
targetY: dp.y + Math.sin(angle) * radius,
|
||||
r: 18,
|
||||
};
|
||||
});
|
||||
|
||||
const staticCtx = staticCanvas.getContext("2d");
|
||||
if (!staticCtx) return;
|
||||
|
||||
// Draw static layer once
|
||||
drawStaticLayer(staticCtx, staticCanvas, domainPositions);
|
||||
|
||||
// Reset animation state
|
||||
settledRef.current = false;
|
||||
startTimeRef.current = performance.now();
|
||||
timeRef.current = 0;
|
||||
lastFrameTimeRef.current = 0;
|
||||
|
||||
// Start animation
|
||||
runAnimation();
|
||||
|
||||
return () => {
|
||||
if (animRef.current) {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
animRef.current = 0;
|
||||
}
|
||||
};
|
||||
}, [entities, ontology, containerSize, drawStaticLayer, runAnimation]);
|
||||
|
||||
// Restart animation when highlights change (without resetting positions)
|
||||
useEffect(() => {
|
||||
const hasHighlights = highlightedEntities && highlightedEntities.length > 0;
|
||||
|
||||
// If we have highlights and animation isn't running, restart it
|
||||
if (hasHighlights && animRef.current === 0) {
|
||||
runAnimation();
|
||||
}
|
||||
}, [highlightedEntities, runAnimation]);
|
||||
|
||||
// Redraw on filter change (without resetting)
|
||||
useEffect(() => {
|
||||
const nodesCanvas = nodesCanvasRef.current;
|
||||
const edgesCanvas = edgesCanvasRef.current;
|
||||
const nodesCtx = nodesCanvas?.getContext("2d");
|
||||
const edgesCtx = edgesCanvas?.getContext("2d");
|
||||
|
||||
if (nodesCtx && nodesCanvas && edgesCtx && edgesCanvas && settledRef.current && animRef.current === 0) {
|
||||
drawNodesLayer(nodesCtx, nodesCanvas, timeRef.current);
|
||||
drawEdgesLayer(edgesCtx, edgesCanvas, timeRef.current);
|
||||
}
|
||||
}, [activeFilter, drawNodesLayer, drawEdgesLayer]);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent<HTMLCanvasElement>) => {
|
||||
// Handle panning first
|
||||
if (isPanningRef.current) {
|
||||
const dx = (e.clientX - lastPanPosRef.current.x) * 2;
|
||||
const dy = (e.clientY - lastPanPosRef.current.y) * 2;
|
||||
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
setPan(p => ({ x: p.x + dx, y: p.y + dy }));
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = nodesCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
// Transform screen coordinates to world coordinates (accounting for zoom/pan)
|
||||
const screenX = (e.clientX - rect.left) * 2;
|
||||
const screenY = (e.clientY - rect.top) * 2;
|
||||
const x = (screenX - panRef.current.x) / zoomRef.current;
|
||||
const y = (screenY - panRef.current.y) / zoomRef.current;
|
||||
|
||||
const nodes = nodesRef.current;
|
||||
let found: string | null = null;
|
||||
for (const node of nodes) {
|
||||
const dx = node.x - x;
|
||||
const dy = node.y - y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < node.r * 1.5) {
|
||||
found = node.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const wasHovered = hoveredRef.current;
|
||||
hoveredRef.current = found;
|
||||
setHovered(found);
|
||||
canvas.style.cursor = isPanningRef.current ? "grabbing" : (found ? "pointer" : "default");
|
||||
|
||||
// Redraw if hover state changed and we're settled
|
||||
if (wasHovered !== found && settledRef.current) {
|
||||
const nodesCanvas = nodesCanvasRef.current;
|
||||
const edgesCanvas = edgesCanvasRef.current;
|
||||
const nodesCtx = nodesCanvas?.getContext("2d");
|
||||
const edgesCtx = edgesCanvas?.getContext("2d");
|
||||
|
||||
if (nodesCtx && nodesCanvas && edgesCtx && edgesCanvas) {
|
||||
drawNodesLayer(nodesCtx, nodesCanvas, timeRef.current);
|
||||
drawEdgesLayer(edgesCtx, edgesCanvas, timeRef.current);
|
||||
}
|
||||
}
|
||||
}, [drawNodesLayer, drawEdgesLayer]);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLCanvasElement>) => {
|
||||
// Don't trigger click if we were panning
|
||||
if (e.shiftKey) return;
|
||||
if (hoveredRef.current && onNodeClick) {
|
||||
const node = nodesRef.current.find((n) => n.id === hoveredRef.current);
|
||||
if (node) onNodeClick(node);
|
||||
}
|
||||
}, [onNodeClick]);
|
||||
|
||||
// Redraw all layers (used when zoom/pan changes)
|
||||
const redrawAllLayers = useCallback(() => {
|
||||
const staticCanvas = staticCanvasRef.current;
|
||||
const nodesCanvas = nodesCanvasRef.current;
|
||||
const edgesCanvas = edgesCanvasRef.current;
|
||||
const staticCtx = staticCanvas?.getContext("2d");
|
||||
const nodesCtx = nodesCanvas?.getContext("2d");
|
||||
const edgesCtx = edgesCanvas?.getContext("2d");
|
||||
|
||||
if (!staticCtx || !staticCanvas || !nodesCtx || !nodesCanvas || !edgesCtx || !edgesCanvas) return;
|
||||
|
||||
// Recalculate domain positions for static layer redraw
|
||||
const cx = staticCanvas.width / 2;
|
||||
const cy = staticCanvas.height / 2;
|
||||
const domainKeys = Object.keys(ontologyRef.current);
|
||||
const domainPositions: Record<DomainKey, { x: number; y: number }> = {};
|
||||
domainKeys.forEach((domain, i) => {
|
||||
const angle = (Math.PI * 2 * i) / domainKeys.length - Math.PI / 2;
|
||||
const radius = Math.min(cx, cy) * 0.45;
|
||||
domainPositions[domain] = {
|
||||
x: cx + Math.cos(angle) * radius,
|
||||
y: cy + Math.sin(angle) * radius,
|
||||
};
|
||||
});
|
||||
|
||||
drawStaticLayer(staticCtx, staticCanvas, domainPositions);
|
||||
drawEdgesLayer(edgesCtx, edgesCanvas, timeRef.current);
|
||||
drawNodesLayer(nodesCtx, nodesCanvas, timeRef.current);
|
||||
}, [drawStaticLayer, drawEdgesLayer, drawNodesLayer]);
|
||||
|
||||
// Zoom handler - zoom towards cursor position
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.min(4, Math.max(0.25, zoomRef.current * delta));
|
||||
|
||||
const canvas = nodesCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cursorX = (e.clientX - rect.left) * 2; // Account for 2x canvas scaling
|
||||
const cursorY = (e.clientY - rect.top) * 2;
|
||||
|
||||
// Adjust pan to zoom towards cursor
|
||||
const zoomRatio = newZoom / zoomRef.current;
|
||||
const newPanX = cursorX - (cursorX - panRef.current.x) * zoomRatio;
|
||||
const newPanY = cursorY - (cursorY - panRef.current.y) * zoomRatio;
|
||||
|
||||
setZoom(newZoom);
|
||||
setPan({ x: newPanX, y: newPanY });
|
||||
}, []);
|
||||
|
||||
// Pan handlers
|
||||
const handleMouseDown = useCallback((e: MouseEvent<HTMLCanvasElement>) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
isPanningRef.current = true;
|
||||
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isPanningRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Reset zoom/pan
|
||||
const handleResetView = useCallback(() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
// Redraw when zoom/pan changes
|
||||
useEffect(() => {
|
||||
if (containerSize.width > 0) {
|
||||
redrawAllLayers();
|
||||
}
|
||||
}, [zoom, pan, containerSize, redrawAllLayers]);
|
||||
|
||||
const canvasStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Layer 1: Static (grid + domain labels) */}
|
||||
<canvas ref={staticCanvasRef} style={canvasStyle} />
|
||||
{/* Layer 2: Edges */}
|
||||
<canvas ref={edgesCanvasRef} style={canvasStyle} />
|
||||
{/* Layer 3: Nodes (on top for interaction) */}
|
||||
<canvas
|
||||
ref={nodesCanvasRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
onWheel={handleWheel}
|
||||
style={canvasStyle}
|
||||
/>
|
||||
|
||||
<ZoomControls
|
||||
zoom={zoom}
|
||||
onZoomIn={() => setZoom(z => Math.min(4, z * 1.2))}
|
||||
onZoomOut={() => setZoom(z => Math.max(0.25, z / 1.2))}
|
||||
onReset={handleResetView}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hovered && (() => {
|
||||
const node = nodesRef.current.find((n) => n.id === hovered);
|
||||
if (!node) return null;
|
||||
// Transform node position to screen coordinates
|
||||
const sx = (node.x * zoomRef.current + panRef.current.x) / 2;
|
||||
const sy = (node.y * zoomRef.current + panRef.current.y) / 2;
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute", left: sx + 20, top: sy - 20,
|
||||
background: "rgba(15,15,20,0.95)", border: `1px solid ${node.color}44`,
|
||||
borderRadius: 8, padding: "10px 14px", pointerEvents: "none",
|
||||
backdropFilter: "blur(12px)", zIndex: 10, minWidth: 180,
|
||||
}}>
|
||||
<div style={{ color: node.color, fontWeight: 700, fontSize: 13, fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{node.icon} {node.label}
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 11, marginTop: 4, fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{Object.entries(node.props || {}).map(([k, v]) => (
|
||||
<div key={k}><span style={{ color: "#666" }}>{k}:</span> <span style={{ color: "#ccc" }}>{String(v)}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import type { DomainKey, Entity, GraphNode, OntologyType, Relationship } from "../../types";
|
||||
import { ZoomControls } from "./ZoomControls";
|
||||
import { border } from "../../theme";
|
||||
|
||||
interface GraphCanvasSVGProps {
|
||||
entities: Entity[];
|
||||
relationships: Relationship[];
|
||||
ontology: OntologyType;
|
||||
highlightedEntities: string[];
|
||||
onNodeClick: (node: GraphNode) => void;
|
||||
activeFilter: DomainKey | null;
|
||||
}
|
||||
|
||||
const SETTLE_TIME = 10000; // 10 seconds until nodes settle
|
||||
|
||||
export function GraphCanvasSVG({ entities, relationships, ontology, highlightedEntities, onNodeClick, activeFilter }: GraphCanvasSVGProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [settled, setSettled] = useState(false);
|
||||
const [time, setTime] = useState(0);
|
||||
const animRef = useRef<number>(0);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const lastFrameTimeRef = useRef<number>(0);
|
||||
|
||||
// Zoom and pan state
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const isPanningRef = useRef(false);
|
||||
const lastPanPosRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Track container size
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setContainerSize({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Calculate node positions
|
||||
const { nodes, domainPositions } = useMemo(() => {
|
||||
if (containerSize.width === 0) return { nodes: [], domainPositions: {} };
|
||||
|
||||
const width = containerSize.width;
|
||||
const height = containerSize.height;
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
|
||||
const domainKeys = Object.keys(ontology);
|
||||
const domainPositions: Record<DomainKey, { x: number; y: number }> = {};
|
||||
domainKeys.forEach((domain, i) => {
|
||||
const angle = (Math.PI * 2 * i) / domainKeys.length - Math.PI / 2;
|
||||
const radius = Math.min(cx, cy) * 0.45;
|
||||
domainPositions[domain] = {
|
||||
x: cx + Math.cos(angle) * radius,
|
||||
y: cy + Math.sin(angle) * radius,
|
||||
};
|
||||
});
|
||||
|
||||
const nodes: GraphNode[] = entities.map((e) => {
|
||||
const dp = domainPositions[e.domain];
|
||||
const subIdx = ontology[e.domain].subclasses.findIndex((s) => s.id === e.id);
|
||||
const total = ontology[e.domain].subclasses.length;
|
||||
const angle = ((Math.PI * 2) / total) * subIdx - Math.PI / 2;
|
||||
const radius = Math.min(width, height) * 0.1;
|
||||
const x = dp.x + Math.cos(angle) * radius;
|
||||
const y = dp.y + Math.sin(angle) * radius;
|
||||
return {
|
||||
...e,
|
||||
x,
|
||||
y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
targetX: x,
|
||||
targetY: y,
|
||||
r: 9, // Half size since we're not doing 2x canvas scaling
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes, domainPositions };
|
||||
}, [entities, ontology, containerSize]);
|
||||
|
||||
// Animation loop for breathing effect
|
||||
useEffect(() => {
|
||||
if (containerSize.width === 0) return;
|
||||
|
||||
startTimeRef.current = performance.now();
|
||||
setSettled(false);
|
||||
setTime(0);
|
||||
|
||||
const frameInterval = 1000 / 30; // 30fps
|
||||
|
||||
function animate(currentTime: number) {
|
||||
if (currentTime - lastFrameTimeRef.current < frameInterval) {
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
lastFrameTimeRef.current = currentTime;
|
||||
|
||||
// Check if should settle
|
||||
if (currentTime - startTimeRef.current > SETTLE_TIME) {
|
||||
setSettled(true);
|
||||
// Continue animation only if there are highlights
|
||||
if (highlightedEntities && highlightedEntities.length > 0) {
|
||||
setTime(t => t + 0.01);
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setTime(t => t + 0.01);
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [containerSize, entities, ontology]);
|
||||
|
||||
// Restart animation when highlights change
|
||||
useEffect(() => {
|
||||
if (highlightedEntities && highlightedEntities.length > 0 && settled && animRef.current === 0) {
|
||||
const frameInterval = 1000 / 30;
|
||||
|
||||
function animate(currentTime: number) {
|
||||
if (currentTime - lastFrameTimeRef.current < frameInterval) {
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
lastFrameTimeRef.current = currentTime;
|
||||
setTime(t => t + 0.01);
|
||||
|
||||
if (highlightedEntities && highlightedEntities.length > 0) {
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
animRef.current = 0;
|
||||
}
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
}, [highlightedEntities, settled]);
|
||||
|
||||
// Generate grid lines
|
||||
const gridLines = useMemo(() => {
|
||||
const lines: React.ReactElement[] = [];
|
||||
const { width, height } = containerSize;
|
||||
if (width === 0) return lines;
|
||||
|
||||
for (let x = 0; x < width; x += 30) {
|
||||
lines.push(
|
||||
<line key={`v-${x}`} x1={x} y1={0} x2={x} y2={height} stroke={border.grid} strokeWidth={0.5} />
|
||||
);
|
||||
}
|
||||
for (let y = 0; y < height; y += 30) {
|
||||
lines.push(
|
||||
<line key={`h-${y}`} x1={0} y1={y} x2={width} y2={y} stroke={border.grid} strokeWidth={0.5} />
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}, [containerSize]);
|
||||
|
||||
// Calculate edge path with curve
|
||||
const getEdgePath = useCallback((fromNode: GraphNode, toNode: GraphNode, time: number, isSettled: boolean) => {
|
||||
const driftX1 = isSettled ? 0 : Math.sin(time + fromNode.targetX * 0.01) * 0.3;
|
||||
const driftY1 = isSettled ? 0 : Math.cos(time + fromNode.targetY * 0.01) * 0.3;
|
||||
const driftX2 = isSettled ? 0 : Math.sin(time + toNode.targetX * 0.01) * 0.3;
|
||||
const driftY2 = isSettled ? 0 : Math.cos(time + toNode.targetY * 0.01) * 0.3;
|
||||
|
||||
const x1 = fromNode.x + driftX1;
|
||||
const y1 = fromNode.y + driftY1;
|
||||
const x2 = toNode.x + driftX2;
|
||||
const y2 = toNode.y + driftY2;
|
||||
|
||||
const mx = (x1 + x2) / 2 + (y1 - y2) * 0.1;
|
||||
const my = (y1 + y2) / 2 + (x2 - x1) * 0.1;
|
||||
|
||||
return { path: `M ${x1} ${y1} Q ${mx} ${my} ${x2} ${y2}`, mx, my, x1, y1, x2, y2 };
|
||||
}, []);
|
||||
|
||||
// Get node position with drift
|
||||
const getNodePosition = useCallback((node: GraphNode, time: number, isSettled: boolean) => {
|
||||
if (isSettled) {
|
||||
return { x: node.x, y: node.y };
|
||||
}
|
||||
const driftX = Math.sin(time + node.targetX * 0.01) * 0.3;
|
||||
const driftY = Math.cos(time + node.targetY * 0.01) * 0.3;
|
||||
return { x: node.x + driftX, y: node.y + driftY };
|
||||
}, []);
|
||||
|
||||
const handleNodeClick = useCallback((node: GraphNode) => {
|
||||
onNodeClick(node);
|
||||
}, [onNodeClick]);
|
||||
|
||||
// Zoom handler - zoom towards cursor position
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.min(4, Math.max(0.25, zoom * delta));
|
||||
|
||||
// Get cursor position relative to SVG
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const cursorX = e.clientX - rect.left;
|
||||
const cursorY = e.clientY - rect.top;
|
||||
|
||||
// Adjust pan to zoom towards cursor
|
||||
const zoomRatio = newZoom / zoom;
|
||||
const newPanX = cursorX - (cursorX - pan.x) * zoomRatio;
|
||||
const newPanY = cursorY - (cursorY - pan.y) * zoomRatio;
|
||||
|
||||
setZoom(newZoom);
|
||||
setPan({ x: newPanX, y: newPanY });
|
||||
}, [zoom, pan]);
|
||||
|
||||
// Pan handlers
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Only pan with middle mouse or when holding space (we'll just use middle mouse for now)
|
||||
if (e.button === 1 || e.button === 0 && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
isPanningRef.current = true;
|
||||
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isPanningRef.current) return;
|
||||
const dx = e.clientX - lastPanPosRef.current.x;
|
||||
const dy = e.clientY - lastPanPosRef.current.y;
|
||||
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
setPan(p => ({ x: p.x + dx, y: p.y + dy }));
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isPanningRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Reset zoom/pan
|
||||
const handleResetView = useCallback(() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
if (containerSize.width === 0) {
|
||||
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
||||
}
|
||||
|
||||
const filteredRels = activeFilter
|
||||
? relationships.filter((r) => r.domain.includes(activeFilter))
|
||||
: relationships;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={containerSize.width}
|
||||
height={containerSize.height}
|
||||
style={{ display: "block", background: "transparent", cursor: isPanningRef.current ? "grabbing" : "default" }}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{/* Grid - outside transform so it stays fixed */}
|
||||
<g>{gridLines}</g>
|
||||
|
||||
{/* Transformed content */}
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||||
|
||||
{/* Domain labels */}
|
||||
<g>
|
||||
{(Object.entries(domainPositions) as [DomainKey, { x: number; y: number }][]).map(([domain, pos]) => {
|
||||
const data = ontology[domain];
|
||||
return (
|
||||
<text
|
||||
key={domain}
|
||||
x={pos.x}
|
||||
y={pos.y - Math.min(containerSize.width, containerSize.height) * 0.14}
|
||||
fill={data.color + "44"}
|
||||
fontSize={11}
|
||||
fontWeight="bold"
|
||||
fontFamily="'IBM Plex Mono', monospace"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{data.label.toUpperCase()}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Edges */}
|
||||
<g>
|
||||
{filteredRels.map((rel, i) => {
|
||||
const fromNode = nodes.find((n) => n.id === rel.from);
|
||||
const toNode = nodes.find((n) => n.id === rel.to);
|
||||
if (!fromNode || !toNode) return null;
|
||||
|
||||
const isHighlighted =
|
||||
highlightedEntities &&
|
||||
highlightedEntities.includes(rel.from) &&
|
||||
highlightedEntities.includes(rel.to);
|
||||
|
||||
const { path, mx, my, x1, y1, x2, y2 } = getEdgePath(fromNode, toNode, time, settled);
|
||||
const baseAlpha = isHighlighted ? 0.7 : 0.12;
|
||||
const pulse = isHighlighted ? Math.sin(time * 4) * 0.15 + 0.15 : 0;
|
||||
const alpha = Math.min(1, baseAlpha + pulse);
|
||||
|
||||
// Particle position on curve (quadratic bezier)
|
||||
const t = (time * 2) % 1;
|
||||
const px = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * mx + t * t * x2;
|
||||
const py = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * my + t * t * y2;
|
||||
|
||||
return (
|
||||
<g key={`${rel.from}-${rel.predicate}-${rel.to}-${i}`}>
|
||||
<defs>
|
||||
<linearGradient id={`grad-${rel.from}-${rel.to}-${i}`} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={fromNode.color} stopOpacity={alpha} />
|
||||
<stop offset="100%" stopColor={toNode.color} stopOpacity={alpha} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={path}
|
||||
stroke={`url(#grad-${rel.from}-${rel.to}-${i})`}
|
||||
strokeWidth={isHighlighted ? 1.5 : 0.75}
|
||||
fill="none"
|
||||
/>
|
||||
{isHighlighted && (
|
||||
<circle cx={px} cy={py} r={1.5} fill="#fff" />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Nodes */}
|
||||
<g>
|
||||
{nodes.map((node) => {
|
||||
const isHighlighted = highlightedEntities && highlightedEntities.includes(node.id);
|
||||
const isHovered = hovered === node.id;
|
||||
const isDimmed = highlightedEntities && highlightedEntities.length > 0 && !isHighlighted;
|
||||
const isFiltered = activeFilter && node.domain !== activeFilter && !relationships.some(
|
||||
r => r.domain.includes(activeFilter) && (r.from === node.id || r.to === node.id)
|
||||
);
|
||||
|
||||
const alpha = isFiltered ? 0.15 : isDimmed ? 0.3 : 1;
|
||||
const r = isHighlighted || isHovered ? node.r * 1.4 : node.r;
|
||||
const pulseR = isHighlighted && !settled ? Math.sin(time * 3) * 1.5 : 0;
|
||||
const { x, y } = getNodePosition(node, time, settled);
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
onMouseEnter={() => setHovered(node.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{/* Glow */}
|
||||
{(isHighlighted || isHovered) && !isFiltered && (
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={r + 6 + pulseR}
|
||||
fill="url(#glow)"
|
||||
opacity={0.5}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`glow-${node.id}`}>
|
||||
<stop offset="0%" stopColor={node.color} stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor={node.color} stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</circle>
|
||||
)}
|
||||
|
||||
{/* Node circle */}
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={r}
|
||||
fill={node.color}
|
||||
fillOpacity={alpha * 0.2}
|
||||
stroke={node.color}
|
||||
strokeOpacity={alpha}
|
||||
strokeWidth={isHighlighted ? 1.25 : 0.75}
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={x}
|
||||
y={y + r + 9}
|
||||
fill={`rgba(255,255,255,${alpha * (isHighlighted ? 1 : 0.75)})`}
|
||||
fontSize={isHovered ? 8.5 : 7}
|
||||
fontWeight={isHighlighted ? "bold" : "normal"}
|
||||
fontFamily="'IBM Plex Sans', sans-serif"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</g>{/* Close transform group */}
|
||||
</svg>
|
||||
|
||||
<ZoomControls
|
||||
zoom={zoom}
|
||||
onZoomIn={() => setZoom(z => Math.min(4, z * 1.2))}
|
||||
onZoomOut={() => setZoom(z => Math.max(0.25, z / 1.2))}
|
||||
onReset={handleResetView}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hovered && (() => {
|
||||
const node = nodes.find((n) => n.id === hovered);
|
||||
if (!node) return null;
|
||||
const { x, y } = getNodePosition(node, time, settled);
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute", left: x + 20, top: y - 20,
|
||||
background: "rgba(15,15,20,0.95)", border: `1px solid ${node.color}44`,
|
||||
borderRadius: 8, padding: "10px 14px", pointerEvents: "none",
|
||||
backdropFilter: "blur(12px)", zIndex: 10, minWidth: 180,
|
||||
}}>
|
||||
<div style={{ color: node.color, fontWeight: 700, fontSize: 13, fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{node.icon} {node.label}
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 11, marginTop: 4, fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{Object.entries(node.props || {}).map(([k, v]) => (
|
||||
<div key={k}><span style={{ color: "#666" }}>{k}:</span> <span style={{ color: "#ccc" }}>{String(v)}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import type { Entity, Relationship, OntologyType } from "../../types";
|
||||
import { SectionLabel, Card } from "../common";
|
||||
import { text, border } from "../../theme";
|
||||
|
||||
interface NodeDetailPanelProps {
|
||||
node: Entity;
|
||||
relationships: Relationship[];
|
||||
entities: Entity[];
|
||||
ontology: OntologyType;
|
||||
propertyLabels: Record<string, string>;
|
||||
onClose: () => void;
|
||||
onNodeSelect: (node: Entity) => void;
|
||||
}
|
||||
|
||||
export function NodeDetailPanel({ node, relationships, entities, ontology, propertyLabels, onClose, onNodeSelect }: NodeDetailPanelProps) {
|
||||
// Filter relationships for this node
|
||||
const nodeRelationships = relationships.filter(
|
||||
r => r.from === node.id || r.to === node.id
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 320, flexShrink: 0, borderLeft: `1px solid ${border.default}`,
|
||||
background: "rgba(12,12,18,0.95)", padding: 24, overflowY: "auto",
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
|
||||
<div style={{ color: ontology[node.domain].color, fontSize: 11, fontFamily: "'IBM Plex Mono', monospace", fontWeight: 600 }}>
|
||||
{ontology[node.domain].label.toUpperCase()} ENTITY
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", color: text.faint, cursor: "pointer", fontSize: 18 }}>×</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#fff", marginBottom: 6 }}>
|
||||
{node.icon} {node.label}
|
||||
</div>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<SectionLabel>PROPERTIES</SectionLabel>
|
||||
{Object.entries(node.props || {}).map(([k, v]) => (
|
||||
<div key={k} style={{ display: "flex", justifyContent: "space-between", padding: "8px 0", borderBottom: `1px solid ${border.subtle}` }}>
|
||||
<span style={{ fontSize: 12, color: text.subtle }}>{propertyLabels[k] || k}</span>
|
||||
<span style={{ fontSize: 12, color: text.primary, fontFamily: "'IBM Plex Mono', monospace", textAlign: "right" }}>{String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<SectionLabel>RELATIONSHIPS</SectionLabel>
|
||||
{nodeRelationships.map((r, i) => {
|
||||
const otherId = r.from === node.id ? r.to : r.from;
|
||||
const other = entities.find(e => e.id === otherId);
|
||||
const direction = r.from === node.id ? "→" : "←";
|
||||
return (
|
||||
<Card
|
||||
key={i}
|
||||
padding="8px 10px"
|
||||
borderRadius={6}
|
||||
onClick={() => { if (other) onNodeSelect(other); }}
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: text.muted }}>
|
||||
<span style={{ color: other?.color || text.subtle }}>{direction} {other?.label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: text.faint, fontFamily: "'IBM Plex Mono', monospace", marginTop: 2 }}>
|
||||
{r.predicate.replace(/_/g, " ")}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { surface, border, text } from "../../theme";
|
||||
|
||||
interface ZoomControlsProps {
|
||||
zoom: number;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ZoomControls({ zoom, onZoomIn, onZoomOut, onReset }: ZoomControlsProps) {
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
width: 28,
|
||||
height: 28,
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
background: border.medium,
|
||||
color: text.subtle,
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Zoom controls */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
background: surface.overlayLight,
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
border: `1px solid ${border.medium}`,
|
||||
}}>
|
||||
<button
|
||||
onClick={onZoomIn}
|
||||
style={buttonStyle}
|
||||
title="Zoom in"
|
||||
>+</button>
|
||||
<button
|
||||
onClick={onZoomOut}
|
||||
style={buttonStyle}
|
||||
title="Zoom out"
|
||||
>−</button>
|
||||
<button
|
||||
onClick={onReset}
|
||||
style={{ ...buttonStyle, fontSize: 10 }}
|
||||
title="Reset view"
|
||||
>⟲</button>
|
||||
</div>
|
||||
|
||||
{/* Zoom indicator */}
|
||||
{zoom !== 1 && (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
fontSize: 11,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
color: text.faint,
|
||||
background: surface.overlayLight,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export { GraphCanvas } from "./GraphCanvas";
|
||||
export { GraphCanvasSVG } from "./GraphCanvasSVG";
|
||||
export { ExplainGraph } from "./ExplainGraph";
|
||||
export type { ExplainGraphNode, ExplainGraphEdge } from "./ExplainGraph";
|
||||
export { NodeDetailPanel } from "./NodeDetailPanel";
|
||||
export { ZoomControls } from "./ZoomControls";
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
// Common shared components
|
||||
export { SectionLabel, FilterButton, Header, StatusBar, Typewriter, Card, Badge, LoadingState, Toaster, SearchInput, FilterBar, MessageBubble } from "./common";
|
||||
export type { FilterItem, Message } from "./common";
|
||||
|
||||
// Graph visualization components
|
||||
export { GraphCanvas, GraphCanvasSVG, ExplainGraph, NodeDetailPanel, ZoomControls } from "./graph";
|
||||
export type { ExplainGraphNode, ExplainGraphEdge } from "./graph";
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// TrustGraph collection identifier
|
||||
export const COLLECTION = "default";
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0A0A0F;
|
||||
}
|
||||
|
||||
body {
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { SocketProvider } from '@trustgraph/react-provider'
|
||||
import { NotificationProvider, NotificationHandler } from '@trustgraph/react-state'
|
||||
import { toast } from './state'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const notificationHandler: NotificationHandler = {
|
||||
success: (message: string) => toast.success(message),
|
||||
error: (message: string) => toast.error(message),
|
||||
warning: (message: string) => toast.warning(message),
|
||||
info: (message: string) => toast.info(message),
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NotificationProvider handler={notificationHandler}>
|
||||
<SocketProvider user="trustgraph">
|
||||
<App />
|
||||
</SocketProvider>
|
||||
</NotificationProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
@ -1,393 +0,0 @@
|
|||
import { useState, useCallback, useMemo } from "react";
|
||||
import { SectionLabel, Card, LoadingState, SearchInput, FilterBar } from "../components";
|
||||
import type { FilterItem } from "../components";
|
||||
import { useSchemas, useEmbeddings, useRowEmbeddingsQuery, useRowsQuery } from "@trustgraph/react-state";
|
||||
import { COLLECTION } from "../config";
|
||||
import { semantic, palette, text, border, surface } from "../theme";
|
||||
|
||||
// Schema field type
|
||||
interface SchemaField {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Schema type based on what useSchemas returns
|
||||
interface SchemaData {
|
||||
name: string;
|
||||
description?: string;
|
||||
fields?: SchemaField[];
|
||||
indexes?: { name: string; fields: string[] }[];
|
||||
}
|
||||
|
||||
interface SchemaInfo {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
fields: SchemaField[];
|
||||
indexes: { name: string; fields: string[] }[];
|
||||
}
|
||||
|
||||
// Type for accumulated results with schema info and row data
|
||||
interface AccumulatedMatch {
|
||||
schemaKey: string;
|
||||
index_name: string;
|
||||
index_value: string[];
|
||||
text: string;
|
||||
score: number;
|
||||
rowData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function DataView() {
|
||||
// Input state
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Filter state (display only - doesn't trigger re-fetch)
|
||||
const [selectedSchema, setSelectedSchema] = useState<string | null>(null);
|
||||
|
||||
// Results state
|
||||
const [allMatches, setAllMatches] = useState<AccumulatedMatch[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
// Fetch schemas
|
||||
const { schemas: rawSchemas, schemasLoading, schemasError } = useSchemas();
|
||||
|
||||
// Embeddings hook - we'll use refetch for manual triggering
|
||||
const [embeddingsTerm, setEmbeddingsTerm] = useState("");
|
||||
const { embeddings, isLoading: embeddingsLoading, refetch: _refetchEmbeddings } = useEmbeddings({
|
||||
flow: "default",
|
||||
term: embeddingsTerm,
|
||||
});
|
||||
|
||||
// Row embeddings query
|
||||
const { executeQueryAsync } = useRowEmbeddingsQuery({ flow: "default" });
|
||||
|
||||
// Rows query for fetching full row data
|
||||
const { executeQueryAsync: executeRowsQueryAsync } = useRowsQuery({ flow: "default" });
|
||||
|
||||
// Parse schemas into usable format
|
||||
const schemas: SchemaInfo[] = useMemo(() => {
|
||||
return (rawSchemas || []).map((s: unknown, idx: number) => {
|
||||
if (Array.isArray(s)) {
|
||||
const schemaData = s[1] as SchemaData | undefined;
|
||||
return {
|
||||
key: String(s[0]),
|
||||
name: schemaData?.name || String(s[0]),
|
||||
description: schemaData?.description,
|
||||
fields: schemaData?.fields || [],
|
||||
indexes: schemaData?.indexes || [],
|
||||
};
|
||||
}
|
||||
const schemaObj = s as SchemaData & { key?: string };
|
||||
return {
|
||||
key: schemaObj.key || schemaObj.name || `schema-${idx}`,
|
||||
name: schemaObj.name || `Schema ${idx}`,
|
||||
description: schemaObj.description,
|
||||
fields: schemaObj.fields || [],
|
||||
indexes: schemaObj.indexes || [],
|
||||
};
|
||||
});
|
||||
}, [rawSchemas]);
|
||||
|
||||
// Build GraphQL query for a schema
|
||||
const buildGraphQLQuery = useCallback((schema: SchemaInfo) => {
|
||||
const gqlName = schema.key.replace(/-/g, '_');
|
||||
const fieldNames = schema.fields.map(f => f.name).join('\n ');
|
||||
return `query { ${gqlName} { ${fieldNames} } }`;
|
||||
}, []);
|
||||
|
||||
// Core search function - searches ALL schemas, stores ALL results
|
||||
const performSearch = useCallback(async (vectors: number[][]) => {
|
||||
try {
|
||||
// Always search ALL schemas
|
||||
const embeddingsResults = await Promise.all(
|
||||
schemas.map(async (schema) => {
|
||||
try {
|
||||
const matches = await executeQueryAsync({
|
||||
vectors,
|
||||
schemaName: schema.key,
|
||||
collection: COLLECTION,
|
||||
limit: 10,
|
||||
});
|
||||
return matches.map(m => ({ ...m, schemaKey: schema.key }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const flatMatches = embeddingsResults.flat();
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Set<string>();
|
||||
const uniqueMatches = flatMatches.filter(match => {
|
||||
const key = `${match.schemaKey}:${match.index_value.join(',')}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Fetch full row data for schemas with matches
|
||||
const schemaKeysWithMatches = [...new Set(uniqueMatches.map(m => m.schemaKey))];
|
||||
const rowDataBySchema: Record<string, Record<string, unknown>[]> = {};
|
||||
|
||||
await Promise.all(
|
||||
schemaKeysWithMatches.map(async (schemaKey) => {
|
||||
const schema = schemas.find(s => s.key === schemaKey);
|
||||
if (!schema || schema.fields.length === 0) return;
|
||||
|
||||
try {
|
||||
const query = buildGraphQLQuery(schema);
|
||||
const result = await executeRowsQueryAsync({ query, collection: COLLECTION });
|
||||
const gqlName = schemaKey.replace(/-/g, '_');
|
||||
const rows = (result?.data as Record<string, unknown[]>)?.[gqlName] || [];
|
||||
rowDataBySchema[schemaKey] = rows as Record<string, unknown>[];
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch rows for ${schemaKey}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Match row data to embeddings results
|
||||
const matchesWithRowData = uniqueMatches.map(match => {
|
||||
const rows = rowDataBySchema[match.schemaKey] || [];
|
||||
const indexFields = match.index_name.split('.');
|
||||
const indexFieldName = indexFields[indexFields.length - 1];
|
||||
|
||||
const matchedRow = rows.find(row => {
|
||||
const rowValue = row[indexFieldName];
|
||||
return match.index_value.some(iv =>
|
||||
String(rowValue).toLowerCase() === iv.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
return { ...match, rowData: matchedRow };
|
||||
});
|
||||
|
||||
setAllMatches(matchesWithRowData);
|
||||
setHasSearched(true);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [schemas, executeQueryAsync, executeRowsQueryAsync, buildGraphQLQuery]);
|
||||
|
||||
// Handle search button click
|
||||
const handleSearch = useCallback(async () => {
|
||||
const term = searchTerm.trim();
|
||||
if (!term) return;
|
||||
|
||||
setIsSearching(true);
|
||||
setAllMatches([]);
|
||||
|
||||
// If same term, use refetch; otherwise set new term
|
||||
if (term === embeddingsTerm && embeddings && embeddings.length > 0) {
|
||||
// Same term - we already have embeddings, just re-run the search
|
||||
await performSearch(embeddings);
|
||||
} else {
|
||||
// New term - update embeddings term and wait for it
|
||||
setEmbeddingsTerm(term);
|
||||
}
|
||||
}, [searchTerm, embeddingsTerm, embeddings, performSearch]);
|
||||
|
||||
// When embeddings become available for a new term, run the search
|
||||
// This only triggers when embeddingsTerm changes and embeddings load
|
||||
const prevEmbeddingsTermRef = useMemo(() => ({ current: "" }), []);
|
||||
|
||||
if (
|
||||
isSearching &&
|
||||
embeddingsTerm &&
|
||||
embeddings &&
|
||||
embeddings.length > 0 &&
|
||||
!embeddingsLoading &&
|
||||
prevEmbeddingsTermRef.current !== embeddingsTerm
|
||||
) {
|
||||
prevEmbeddingsTermRef.current = embeddingsTerm;
|
||||
performSearch(embeddings);
|
||||
}
|
||||
|
||||
// Filter results for display (doesn't affect stored data)
|
||||
const displayMatches = useMemo(() => {
|
||||
if (!selectedSchema) return allMatches;
|
||||
return allMatches.filter(m => m.schemaKey === selectedSchema);
|
||||
}, [allMatches, selectedSchema]);
|
||||
|
||||
// Group filtered matches by schema for display
|
||||
const matchesBySchema = useMemo(() => {
|
||||
return displayMatches.reduce((acc, match) => {
|
||||
if (!acc[match.schemaKey]) {
|
||||
acc[match.schemaKey] = [];
|
||||
}
|
||||
acc[match.schemaKey].push(match);
|
||||
return acc;
|
||||
}, {} as Record<string, AccumulatedMatch[]>);
|
||||
}, [displayMatches]);
|
||||
|
||||
if (schemasLoading) {
|
||||
return <LoadingState message="Loading schemas..." />;
|
||||
}
|
||||
|
||||
if (schemasError) {
|
||||
return <LoadingState variant="error" message="Error loading schemas" />;
|
||||
}
|
||||
|
||||
// Build filter items from schemas
|
||||
const filterItems: FilterItem[] = schemas.slice(0, 10).map((schema) => ({
|
||||
key: schema.key,
|
||||
label: schema.name,
|
||||
}));
|
||||
|
||||
const filterStats = selectedSchema
|
||||
? `${displayMatches.length} of ${allMatches.length} results`
|
||||
: `${allMatches.length} results`;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "calc(100vh - 110px)" }}>
|
||||
{/* Schema Filter Bar */}
|
||||
<FilterBar
|
||||
items={filterItems}
|
||||
selectedKey={selectedSchema}
|
||||
onSelect={setSelectedSchema}
|
||||
stats={filterStats}
|
||||
/>
|
||||
|
||||
{/* Search Input */}
|
||||
<div style={{ padding: "20px 28px", borderBottom: `1px solid ${border.default}` }}>
|
||||
<SectionLabel marginBottom={12}>SEARCH DATA</SectionLabel>
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
onSubmit={handleSearch}
|
||||
placeholder="Search for data across tables..."
|
||||
buttonText="Search"
|
||||
isLoading={isSearching}
|
||||
buttonColor={palette.blue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results Area */}
|
||||
<div style={{ flex: 1, padding: "24px 28px", overflowY: "auto" }}>
|
||||
{!hasSearched && !isSearching ? (
|
||||
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
|
||||
Enter a search term to find data across tables.
|
||||
</div>
|
||||
) : isSearching ? (
|
||||
<div style={{ color: palette.blue, fontSize: 13 }}>
|
||||
Searching...
|
||||
</div>
|
||||
) : displayMatches.length === 0 ? (
|
||||
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
|
||||
{selectedSchema ? "No matches in this schema. Try selecting 'All'." : "No matches found."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
{Object.entries(matchesBySchema).map(([schemaKey, schemaMatches]) => {
|
||||
if (!schemaMatches || schemaMatches.length === 0) return null;
|
||||
const schema = schemas.find(s => s.key === schemaKey);
|
||||
|
||||
return (
|
||||
<Card key={schemaKey} padding={0}>
|
||||
{/* Table Header */}
|
||||
<div style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: `1px solid ${border.default}`,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: palette.blue,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
▤ {schema?.name || schemaKey}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: text.disabled,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
{schemaMatches.length} matches
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results List */}
|
||||
<div>
|
||||
{schemaMatches.map((match, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: `1px solid ${border.subtle}`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = surface.card;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{match.rowData ? (
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: "8px 16px",
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{Object.entries(match.rowData).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
color: text.faint,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
{key}
|
||||
</span>
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
color: text.primary,
|
||||
marginTop: 2,
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{String(value ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
color: text.primary,
|
||||
marginBottom: 6,
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{match.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
fontSize: 11,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
<span style={{
|
||||
color: match.score > 0.8 ? semantic.success : match.score > 0.5 ? palette.amber : text.subtle,
|
||||
}}>
|
||||
{(match.score * 100).toFixed(1)}% match
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,93 +0,0 @@
|
|||
import type { DomainKey, Entity, OntologyDomain } from "../types";
|
||||
import { GraphCanvasSVG as GraphCanvas, NodeDetailPanel, LoadingState, FilterBar } from "../components";
|
||||
import type { FilterItem } from "../components";
|
||||
import { useGraphData } from "../state";
|
||||
|
||||
interface GraphViewProps {
|
||||
activeFilter: DomainKey | null;
|
||||
onFilterChange: (filter: DomainKey | null) => void;
|
||||
selectedNode: Entity | null;
|
||||
onNodeSelect: (node: Entity | null) => void;
|
||||
}
|
||||
|
||||
export function GraphView({ activeFilter, onFilterChange, selectedNode, onNodeSelect }: GraphViewProps) {
|
||||
const { entities, relationships, ontology, propertyLabels, isLoading, isError } = useGraphData();
|
||||
|
||||
const highlightedEntities = selectedNode
|
||||
? [selectedNode.id, ...relationships.filter(r => r.from === selectedNode.id || r.to === selectedNode.id).map(r => r.from === selectedNode.id ? r.to : r.from)]
|
||||
: [];
|
||||
|
||||
// Compute relevant filter domains based on selected node's connections
|
||||
const relevantDomains = selectedNode
|
||||
? (() => {
|
||||
const domains = new Set<DomainKey>([selectedNode.domain]);
|
||||
const connectedIds = relationships
|
||||
.filter(r => r.from === selectedNode.id || r.to === selectedNode.id)
|
||||
.map(r => r.from === selectedNode.id ? r.to : r.from);
|
||||
for (const id of connectedIds) {
|
||||
const entity = entities.find(e => e.id === id);
|
||||
if (entity) domains.add(entity.domain);
|
||||
}
|
||||
return domains;
|
||||
})()
|
||||
: null;
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Loading graph data..." />;
|
||||
}
|
||||
|
||||
if (isError || !ontology) {
|
||||
return <LoadingState variant="error" message="Error loading graph data" />;
|
||||
}
|
||||
|
||||
// Build filter items from relevant domains
|
||||
const filterItems: FilterItem[] = selectedNode
|
||||
? (Object.entries(ontology) as [DomainKey, OntologyDomain][])
|
||||
.filter(([key]) => relevantDomains?.has(key))
|
||||
.slice(0, 10)
|
||||
.map(([key, data]) => ({
|
||||
key,
|
||||
label: data.label,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Domain Filter Bar */}
|
||||
<FilterBar
|
||||
items={filterItems}
|
||||
selectedKey={activeFilter}
|
||||
onSelect={(key) => onFilterChange(key as DomainKey | null)}
|
||||
stats={`${entities.length} entities · ${relationships.length} relationships`}
|
||||
emptyMessage={selectedNode ? undefined : "Select a node to filter"}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div style={{ display: "flex", height: "calc(100vh - 150px)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0, position: "relative", overflow: "hidden" }}>
|
||||
<GraphCanvas
|
||||
entities={entities}
|
||||
relationships={relationships}
|
||||
ontology={ontology}
|
||||
highlightedEntities={highlightedEntities}
|
||||
onNodeClick={(node) => onNodeSelect(selectedNode?.id === node.id ? null : node)}
|
||||
activeFilter={activeFilter}
|
||||
/>
|
||||
</div>
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
node={selectedNode}
|
||||
relationships={relationships}
|
||||
entities={entities}
|
||||
ontology={ontology}
|
||||
propertyLabels={propertyLabels}
|
||||
onClose={() => onNodeSelect(null)}
|
||||
onNodeSelect={onNodeSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import type { DomainKey, OntologyDomain } from "../types";
|
||||
import { SectionLabel, Card, Badge, LoadingState } from "../components";
|
||||
import { useGraphData, useOntologySchema } from "../state";
|
||||
import { getLocalName } from "../utils";
|
||||
import { text, surface, border } from "../theme";
|
||||
|
||||
export function OntologyView() {
|
||||
const { ontology, isLoading: graphLoading } = useGraphData();
|
||||
const { schema, isLoading: schemaLoading } = useOntologySchema();
|
||||
|
||||
const isLoading = graphLoading || schemaLoading;
|
||||
|
||||
if (isLoading || !ontology || !schema) {
|
||||
return <LoadingState message="Loading ontology..." />;
|
||||
}
|
||||
|
||||
// Count total instances
|
||||
const totalInstances = Object.values(ontology).reduce((sum, d) => sum + d.subclasses.length, 0);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, padding: "28px", overflowY: "auto", height: "calc(100vh - 110px)" }}>
|
||||
<div style={{ maxWidth: 900, margin: "0 auto" }}>
|
||||
<SectionLabel marginBottom={24}>ONTOLOGY SCHEMA</SectionLabel>
|
||||
|
||||
{/* Ontology class cards */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 32 }}>
|
||||
{(Object.entries(ontology) as [DomainKey, OntologyDomain][]).map(([key, data]) => {
|
||||
// Find datatype properties for this domain from schema
|
||||
const domainProps = schema.datatypeProperties
|
||||
.filter(p => p.domain && getLocalName(p.domain) === data.label)
|
||||
.map(p => p.label);
|
||||
|
||||
return (
|
||||
<Card key={key} borderColor={data.color + "22"}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 24 }}>{data.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 18, color: data.color }}>{data.label}</div>
|
||||
<div style={{ fontSize: 11, color: text.faint, fontFamily: "'IBM Plex Mono', monospace" }}>owl:Class</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: text.subtle, lineHeight: 1.5, marginBottom: 14 }}>{data.description}</div>
|
||||
<SectionLabel marginBottom={8}>PROPERTIES ({domainProps.length})</SectionLabel>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{domainProps.map((p) => (
|
||||
<Badge key={p} color={data.color} size="small">{p}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<SectionLabel marginTop={14} marginBottom={8}>INSTANCES ({data.subclasses.length})</SectionLabel>
|
||||
{data.subclasses.map((sc) => (
|
||||
<div key={sc.id} style={{
|
||||
padding: "6px 10px", marginBottom: 3, borderRadius: 4,
|
||||
background: surface.card, fontSize: 11, color: text.muted,
|
||||
display: "flex", justifyContent: "space-between",
|
||||
}}>
|
||||
<span>{sc.label}</span>
|
||||
<span style={{ color: text.disabled, fontFamily: "'IBM Plex Mono', monospace", fontSize: 10 }}>{sc.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Relationship predicates (Object Properties) */}
|
||||
<Card borderColor={border.default}>
|
||||
<SectionLabel marginBottom={16}>RELATIONSHIP PREDICATES ({schema.objectProperties.length})</SectionLabel>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||
{schema.objectProperties.map((prop) => {
|
||||
const fromDomain = prop.domain ? getLocalName(prop.domain).toLowerCase() as DomainKey : null;
|
||||
const toDomain = prop.range ? getLocalName(prop.range).toLowerCase() as DomainKey : null;
|
||||
|
||||
return (
|
||||
<Card key={prop.uri} padding="10px 12px" borderRadius={6}>
|
||||
<div style={{ fontSize: 12, color: text.secondary, fontFamily: "'IBM Plex Mono', monospace", marginBottom: 4 }}>
|
||||
{prop.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: text.disabled }}>
|
||||
{fromDomain && ontology[fromDomain] && (
|
||||
<span style={{ color: ontology[fromDomain].color }}>{ontology[fromDomain].label}</span>
|
||||
)}
|
||||
{fromDomain && toDomain && " → "}
|
||||
{toDomain && ontology[toDomain] && (
|
||||
<span style={{ color: ontology[toDomain].color }}>{ontology[toDomain].label}</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Triple count summary */}
|
||||
<div style={{
|
||||
marginTop: 20, padding: "16px 24px", borderRadius: 10,
|
||||
background: "linear-gradient(135deg, rgba(110,231,183,0.04) 0%, rgba(147,197,253,0.04) 50%, rgba(249,168,212,0.04) 100%)",
|
||||
border: `1px solid ${border.default}`,
|
||||
display: "flex", justifyContent: "space-around",
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
{[
|
||||
{ label: "Classes", value: schema.classes.length },
|
||||
{ label: "Instances", value: totalInstances },
|
||||
{ label: "Object Props", value: schema.objectProperties.length },
|
||||
{ label: "Data Props", value: schema.datatypeProperties.length },
|
||||
].map((s) => (
|
||||
<div key={s.label} style={{ textAlign: "center" }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: "#fff" }}>{s.value}</div>
|
||||
<div style={{ fontSize: 10, color: text.faint, letterSpacing: "0.05em" }}>{s.label.toUpperCase()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { GraphCanvasSVG as GraphCanvas, NodeDetailPanel, SectionLabel, Badge, LoadingState, SearchInput, MessageBubble } from "../components";
|
||||
import { useGraphData } from "../state";
|
||||
import { COLLECTION } from "../config";
|
||||
import type { Entity } from "../types";
|
||||
import { useChat, useConversation, useEmbeddings, useGraphEmbeddings } from "@trustgraph/react-state";
|
||||
import { getLocalName } from "../utils";
|
||||
import { palette, text, border, withGlow } from "../theme";
|
||||
|
||||
// Type for embedding result items
|
||||
interface EmbeddingResultItem {
|
||||
id: string;
|
||||
uri: string;
|
||||
label: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
isEntity: boolean;
|
||||
}
|
||||
|
||||
export function QueryView() {
|
||||
const [customInput, setCustomInput] = useState("");
|
||||
const [queryForEmbeddings, setQueryForEmbeddings] = useState<string | undefined>(undefined);
|
||||
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Entity | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { entities, relationships, ontology, propertyLabels, isLoading: graphLoading } = useGraphData();
|
||||
const { submitMessage, isSubmitting } = useChat();
|
||||
const messages = useConversation((state) => state.messages);
|
||||
const setChatMode = useConversation((state) => state.setChatMode);
|
||||
|
||||
// Get embeddings for the query text - only fetch when we have a committed query
|
||||
const { embeddings, isLoading: embeddingsLoading } = useEmbeddings({
|
||||
flow: "default",
|
||||
term: queryForEmbeddings || undefined,
|
||||
});
|
||||
|
||||
// Get graph entities from embeddings - only fetch when we have embeddings
|
||||
const hasEmbeddings = embeddings && embeddings.length > 0;
|
||||
const { graphEmbeddings, isLoading: graphEmbeddingsLoading } = useGraphEmbeddings({
|
||||
vecs: hasEmbeddings ? embeddings : [[]],
|
||||
limit: hasEmbeddings ? 10 : 0,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
// Set chat mode to agent on mount
|
||||
useEffect(() => {
|
||||
setChatMode("agent");
|
||||
}, [setChatMode]);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = (query: string) => {
|
||||
if (query.trim() && !isSubmitting) {
|
||||
const trimmedQuery = query.trim();
|
||||
submitMessage({ input: trimmedQuery });
|
||||
setQueryForEmbeddings(trimmedQuery);
|
||||
setSelectedEntityId(null);
|
||||
setSelectedNode(null);
|
||||
setCustomInput("");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Match graph embedding entities to our loaded entities for labels and highlighting
|
||||
// graphEmbeddings returns RDF terms: { t: "i", i: "http://..." }
|
||||
// Only show matched entities, deduplicated by URI
|
||||
const embeddingResults: EmbeddingResultItem[] = [];
|
||||
const seenUris = new Set<string>();
|
||||
|
||||
for (const ge of (hasEmbeddings && graphEmbeddings || []) as { t: string; i?: string }[]) {
|
||||
const uri = ge.i;
|
||||
if (!uri || seenUris.has(uri)) continue;
|
||||
|
||||
const entityId = getLocalName(uri);
|
||||
const found = entities.find(e => e.id === entityId || e.uri === uri);
|
||||
|
||||
// Only include actual entities, not properties/concepts
|
||||
if (found) {
|
||||
seenUris.add(uri);
|
||||
embeddingResults.push({
|
||||
id: entityId,
|
||||
uri,
|
||||
label: found.label,
|
||||
color: found.color,
|
||||
icon: found.icon,
|
||||
isEntity: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select first embedding result when results arrive
|
||||
useEffect(() => {
|
||||
if (embeddingResults.length > 0 && !selectedEntityId && !selectedNode) {
|
||||
setSelectedEntityId(embeddingResults[0].id);
|
||||
}
|
||||
}, [embeddingResults.length, selectedEntityId, selectedNode]);
|
||||
|
||||
// Extract entity IDs for highlighting on graph
|
||||
// Priority: selectedNode (graph click) > selectedEntityId (button click) > all embedding results
|
||||
const highlightedEntities = (() => {
|
||||
const focusId = selectedNode?.id || selectedEntityId;
|
||||
if (!focusId) {
|
||||
return embeddingResults.map(e => e.id);
|
||||
}
|
||||
// Find all entities connected to the focused entity
|
||||
const connected = new Set<string>([focusId]);
|
||||
for (const rel of relationships) {
|
||||
if (rel.from === focusId) {
|
||||
connected.add(rel.to);
|
||||
} else if (rel.to === focusId) {
|
||||
connected.add(rel.from);
|
||||
}
|
||||
}
|
||||
return Array.from(connected);
|
||||
})();
|
||||
|
||||
if (graphLoading || !ontology) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "calc(100vh - 110px)" }}>
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
{/* Query input area */}
|
||||
<div style={{ padding: "20px 28px", borderBottom: `1px solid ${border.default}` }}>
|
||||
<SectionLabel marginBottom={12}>AGENT QUERIES</SectionLabel>
|
||||
|
||||
<SearchInput
|
||||
value={customInput}
|
||||
onChange={setCustomInput}
|
||||
onSubmit={() => handleSubmit(customInput)}
|
||||
placeholder="Type your own question..."
|
||||
buttonText="Ask"
|
||||
isLoading={isSubmitting}
|
||||
buttonColor={palette.amber}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Related entities from graph embeddings */}
|
||||
{queryForEmbeddings && (
|
||||
<div style={{ padding: "16px 28px", borderBottom: `1px solid ${border.default}` }}>
|
||||
<SectionLabel>
|
||||
RELATED ENTITIES {(embeddingsLoading || graphEmbeddingsLoading) && <span style={{ color: palette.amber }}>loading...</span>}
|
||||
</SectionLabel>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{embeddingResults.length === 0 && !embeddingsLoading && !graphEmbeddingsLoading && (
|
||||
<span style={{ fontSize: 11, color: text.disabled, fontStyle: "italic" }}>No related concepts found</span>
|
||||
)}
|
||||
{embeddingResults.map((item) => {
|
||||
const isSelected = selectedEntityId === item.id;
|
||||
return (
|
||||
<Badge
|
||||
key={item.uri}
|
||||
color={item.color}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
setSelectedEntityId(isSelected ? null : item.id);
|
||||
setSelectedNode(null);
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10 }}>{item.icon}</span>
|
||||
{item.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response area */}
|
||||
<div style={{ flex: 1, padding: "24px 28px", overflowY: "auto" }}>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
|
||||
Type your question to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{messages.map((msg, idx) => (
|
||||
<MessageBubble key={idx} message={msg} />
|
||||
))}
|
||||
{isSubmitting && (
|
||||
<div style={{
|
||||
padding: "8px 12px",
|
||||
fontSize: 11,
|
||||
color: withGlow(palette.amber, 0.4),
|
||||
fontFamily: "'IBM Plex Mono', monospace"
|
||||
}}>
|
||||
Processing...
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph visualization */}
|
||||
<div style={{ width: selectedNode ? "30%" : "45%", borderLeft: `1px solid ${border.default}`, transition: "width 0.2s" }}>
|
||||
<GraphCanvas
|
||||
entities={entities}
|
||||
relationships={relationships}
|
||||
ontology={ontology}
|
||||
highlightedEntities={highlightedEntities}
|
||||
onNodeClick={(node) => {
|
||||
setSelectedNode(selectedNode?.id === node.id ? null : node);
|
||||
setSelectedEntityId(null);
|
||||
}}
|
||||
activeFilter={null}
|
||||
/>
|
||||
</div>
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
node={selectedNode}
|
||||
relationships={relationships}
|
||||
entities={entities}
|
||||
ontology={ontology}
|
||||
propertyLabels={propertyLabels}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
onNodeSelect={(node) => {
|
||||
setSelectedNode(node);
|
||||
setSelectedEntityId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export { GraphView } from "./GraphView";
|
||||
export { QueryView } from "./QueryView";
|
||||
export { ExplainView } from "./ExplainView";
|
||||
export { DataView } from "./DataView";
|
||||
export { OntologyView } from "./OntologyView";
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
// Main data hook - provides entities, relationships, and ontology
|
||||
export { useGraphData } from "./useGraphData";
|
||||
|
||||
// Schema hook - for OWL ontology schema view
|
||||
export { useOntologySchema } from "./useOntologySchema";
|
||||
|
||||
// Toast notifications
|
||||
export { useToastStore, toast } from "./toastStore";
|
||||
export type { Toast, ToastType } from "./toastStore";
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
export type ToastType = "success" | "error" | "warning" | "info";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
persistent?: boolean;
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (type: ToastType, message: string, persistent?: boolean) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
export const useToastStore = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (type, message, persistent = false) => {
|
||||
const id = `toast-${++toastId}`;
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts.slice(-3), { id, type, message, persistent }],
|
||||
}));
|
||||
|
||||
// Auto-dismiss after 6 seconds unless explicitly persistent
|
||||
if (!persistent) {
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, 6000);
|
||||
}
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper functions for easy access outside React
|
||||
export const toast = {
|
||||
success: (message: string) => useToastStore.getState().addToast("success", message),
|
||||
error: (message: string) => useToastStore.getState().addToast("error", message),
|
||||
warning: (message: string) => useToastStore.getState().addToast("warning", message),
|
||||
info: (message: string) => useToastStore.getState().addToast("info", message),
|
||||
};
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useSocket } from "@trustgraph/react-provider";
|
||||
import type { Triple } from "@trustgraph/react-state";
|
||||
import type { Entity, Relationship, DomainKey, OntologyType } from "../types";
|
||||
import { COLLECTION } from "../config";
|
||||
import { domainColors } from "../theme";
|
||||
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const RDFS_COMMENT = "http://www.w3.org/2000/01/rdf-schema#comment";
|
||||
const OWL_CLASS = "http://www.w3.org/2002/07/owl#Class";
|
||||
const OWL_DATATYPE_PROPERTY = "http://www.w3.org/2002/07/owl#DatatypeProperty";
|
||||
const OWL_OBJECT_PROPERTY = "http://www.w3.org/2002/07/owl#ObjectProperty";
|
||||
|
||||
// Helper to extract value from a Term
|
||||
function getTermValue(term: { t: string; i?: string; v?: string }): string {
|
||||
if (term.t === "i") return term.i || "";
|
||||
if (term.t === "l") return term.v || "";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Helper to create a short ID from a URI
|
||||
function uriToId(uri: string): string {
|
||||
const hashIndex = uri.lastIndexOf("#");
|
||||
const slashIndex = uri.lastIndexOf("/");
|
||||
const index = Math.max(hashIndex, slashIndex);
|
||||
return index >= 0 ? uri.substring(index + 1) : uri;
|
||||
}
|
||||
|
||||
// Helper to get icon for a class (placeholder for now)
|
||||
function getClassIcon(_classUri: string): string {
|
||||
return "●";
|
||||
}
|
||||
|
||||
// Helper to extract predicate name from URI
|
||||
function predicateToName(uri: string): string {
|
||||
const hashIndex = uri.lastIndexOf("#");
|
||||
const slashIndex = uri.lastIndexOf("/");
|
||||
const index = Math.max(hashIndex, slashIndex);
|
||||
const name = index >= 0 ? uri.substring(index + 1) : uri;
|
||||
return name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
export function useGraphData(domain?: DomainKey) {
|
||||
const socket = useSocket();
|
||||
const [triples, setTriples] = useState<Triple[] | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
setError(null);
|
||||
|
||||
const api = socket.flow("default");
|
||||
const result = await api.triplesQuery(
|
||||
undefined, undefined, undefined,
|
||||
10000, COLLECTION, "",
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setTriples(result);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setIsError(true);
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [socket]);
|
||||
|
||||
// Process all data from the query
|
||||
const { entities, relationships, ontology, propertyLabels } = useMemo(() => {
|
||||
if (isLoading || !triples) {
|
||||
return { entities: [], relationships: [], ontology: undefined, propertyLabels: {} };
|
||||
}
|
||||
|
||||
// First pass: collect all labels, comments, and find OWL classes and properties
|
||||
const allLabels = new Map<string, string>();
|
||||
const allComments = new Map<string, string>();
|
||||
const owlClasses = new Set<string>();
|
||||
const propertyUris = new Set<string>();
|
||||
|
||||
for (const triple of triples) {
|
||||
const subjectUri = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const objectUri = getTermValue(triple.o);
|
||||
|
||||
if (predicate === RDFS_LABEL) {
|
||||
allLabels.set(subjectUri, getTermValue(triple.o));
|
||||
} else if (predicate === RDFS_COMMENT) {
|
||||
allComments.set(subjectUri, getTermValue(triple.o));
|
||||
} else if (predicate === RDF_TYPE) {
|
||||
if (objectUri === OWL_CLASS) {
|
||||
owlClasses.add(subjectUri);
|
||||
} else if (objectUri === OWL_DATATYPE_PROPERTY || objectUri === OWL_OBJECT_PROPERTY) {
|
||||
propertyUris.add(subjectUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build property labels map: local name -> label
|
||||
const propertyLabels: Record<string, string> = {};
|
||||
for (const propUri of propertyUris) {
|
||||
const localName = uriToId(propUri);
|
||||
const label = allLabels.get(propUri);
|
||||
if (label) {
|
||||
propertyLabels[localName] = label;
|
||||
}
|
||||
}
|
||||
|
||||
// Build class config dynamically from discovered OWL classes
|
||||
const classConfig = new Map<string, { domain: DomainKey; color: string; glow: string; icon: string; label: string; description: string }>();
|
||||
let colorIndex = 0;
|
||||
for (const classUri of owlClasses) {
|
||||
const localName = uriToId(classUri).toLowerCase();
|
||||
const palette = domainColors[colorIndex % domainColors.length];
|
||||
classConfig.set(classUri, {
|
||||
domain: localName,
|
||||
color: palette.color,
|
||||
glow: palette.glow,
|
||||
icon: getClassIcon(classUri),
|
||||
label: allLabels.get(classUri) || uriToId(classUri),
|
||||
description: allComments.get(classUri) || "",
|
||||
});
|
||||
colorIndex++;
|
||||
}
|
||||
|
||||
// Second pass: find entities (instances of OWL classes) and their properties
|
||||
const entityMap = new Map<string, Entity>();
|
||||
const entityProps = new Map<string, Record<string, string | number>>();
|
||||
|
||||
// Collect entity properties first
|
||||
for (const triple of triples) {
|
||||
const subjectUri = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const value = getTermValue(triple.o);
|
||||
|
||||
// Skip schema-level predicates and URIs as values
|
||||
if (predicate !== RDF_TYPE && predicate !== RDFS_LABEL && predicate !== RDFS_COMMENT &&
|
||||
value && !value.startsWith("http")) {
|
||||
if (!entityProps.has(subjectUri)) {
|
||||
entityProps.set(subjectUri, {});
|
||||
}
|
||||
const propName = uriToId(predicate);
|
||||
entityProps.get(subjectUri)![propName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Find entities by type (instances of OWL classes)
|
||||
for (const triple of triples) {
|
||||
const subjectUri = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const objectUri = getTermValue(triple.o);
|
||||
|
||||
if (predicate === RDF_TYPE && classConfig.has(objectUri)) {
|
||||
const config = classConfig.get(objectUri)!;
|
||||
if (domain && config.domain !== domain) continue;
|
||||
|
||||
const entityId = uriToId(subjectUri);
|
||||
entityMap.set(subjectUri, {
|
||||
id: entityId,
|
||||
uri: subjectUri,
|
||||
label: allLabels.get(subjectUri) || entityId,
|
||||
props: entityProps.get(subjectUri) || {},
|
||||
domain: config.domain,
|
||||
color: config.color,
|
||||
glow: config.glow,
|
||||
icon: config.icon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find relationships: triples where both subject and object are known entities
|
||||
const relationships: Relationship[] = [];
|
||||
const entityUris = new Set(entityMap.keys());
|
||||
|
||||
for (const triple of triples) {
|
||||
const subjectUri = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const objectUri = getTermValue(triple.o);
|
||||
|
||||
// Skip rdf:type and rdfs:label
|
||||
if (predicate === RDF_TYPE || predicate === RDFS_LABEL) continue;
|
||||
|
||||
// If both subject and object are entities, it's a relationship
|
||||
if (entityUris.has(subjectUri) && entityUris.has(objectUri)) {
|
||||
const fromEntity = entityMap.get(subjectUri)!;
|
||||
const toEntity = entityMap.get(objectUri)!;
|
||||
|
||||
relationships.push({
|
||||
from: fromEntity.id,
|
||||
to: toEntity.id,
|
||||
predicate: predicateToName(predicate),
|
||||
domain: [fromEntity.domain, toEntity.domain],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const entities = Array.from(entityMap.values());
|
||||
|
||||
// Build ontology metadata dynamically from discovered classes
|
||||
const ontology: OntologyType = {};
|
||||
for (const [, config] of classConfig) {
|
||||
ontology[config.domain] = {
|
||||
label: config.label,
|
||||
color: config.color,
|
||||
glow: config.glow,
|
||||
icon: config.icon,
|
||||
description: config.description,
|
||||
properties: [],
|
||||
subclasses: entities.filter(e => e.domain === config.domain).map(e => ({
|
||||
id: e.id,
|
||||
uri: e.uri,
|
||||
label: e.label,
|
||||
props: e.props,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { entities, relationships, ontology, propertyLabels };
|
||||
}, [isLoading, triples, domain]);
|
||||
|
||||
return {
|
||||
entities,
|
||||
relationships,
|
||||
ontology,
|
||||
propertyLabels,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTriples } from "@trustgraph/react-state";
|
||||
import { COLLECTION } from "../config";
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const RDFS_DOMAIN = "http://www.w3.org/2000/01/rdf-schema#domain";
|
||||
const RDFS_RANGE = "http://www.w3.org/2000/01/rdf-schema#range";
|
||||
const RDFS_COMMENT = "http://www.w3.org/2000/01/rdf-schema#comment";
|
||||
const OWL_CLASS = "http://www.w3.org/2002/07/owl#Class";
|
||||
const OWL_OBJECT_PROPERTY = "http://www.w3.org/2002/07/owl#ObjectProperty";
|
||||
const OWL_DATATYPE_PROPERTY = "http://www.w3.org/2002/07/owl#DatatypeProperty";
|
||||
|
||||
// Helper to extract value from a Term
|
||||
function getTermValue(term: { t: string; i?: string; v?: string }): string {
|
||||
if (term.t === "i") return term.i || "";
|
||||
if (term.t === "l") return term.v || "";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Helper to get local name from URI
|
||||
function getLocalName(uri: string): string {
|
||||
const hashIndex = uri.lastIndexOf("#");
|
||||
const slashIndex = uri.lastIndexOf("/");
|
||||
const index = Math.max(hashIndex, slashIndex);
|
||||
return index >= 0 ? uri.substring(index + 1) : uri;
|
||||
}
|
||||
|
||||
export interface OntologyClass {
|
||||
uri: string;
|
||||
label: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface OntologyProperty {
|
||||
uri: string;
|
||||
label: string;
|
||||
domain?: string;
|
||||
range?: string;
|
||||
}
|
||||
|
||||
export interface OntologySchema {
|
||||
classes: OntologyClass[];
|
||||
objectProperties: OntologyProperty[];
|
||||
datatypeProperties: OntologyProperty[];
|
||||
// Sets for quick lookup
|
||||
objectPropertyUris: Set<string>;
|
||||
datatypePropertyUris: Set<string>;
|
||||
}
|
||||
|
||||
export function useOntologySchema() {
|
||||
// Query for classes
|
||||
const classTriples = useTriples({
|
||||
p: { t: "i", i: RDF_TYPE },
|
||||
o: { t: "i", i: OWL_CLASS },
|
||||
limit: 100,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
// Query for object properties
|
||||
const objectPropertyTriples = useTriples({
|
||||
p: { t: "i", i: RDF_TYPE },
|
||||
o: { t: "i", i: OWL_OBJECT_PROPERTY },
|
||||
limit: 100,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
// Query for datatype properties
|
||||
const datatypePropertyTriples = useTriples({
|
||||
p: { t: "i", i: RDF_TYPE },
|
||||
o: { t: "i", i: OWL_DATATYPE_PROPERTY },
|
||||
limit: 100,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
// Query for all triples to get labels, domains, ranges
|
||||
const allTriples = useTriples({
|
||||
limit: 1000,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
const isLoading = classTriples.isLoading || objectPropertyTriples.isLoading ||
|
||||
datatypePropertyTriples.isLoading || allTriples.isLoading;
|
||||
const isError = classTriples.isError || objectPropertyTriples.isError ||
|
||||
datatypePropertyTriples.isError || allTriples.isError;
|
||||
const error = classTriples.error || objectPropertyTriples.error ||
|
||||
datatypePropertyTriples.error || allTriples.error;
|
||||
|
||||
const schema = useMemo((): OntologySchema | undefined => {
|
||||
if (isLoading) return undefined;
|
||||
|
||||
// Build a map of URI -> metadata from all triples
|
||||
const metadata = new Map<string, { label?: string; domain?: string; range?: string; comment?: string }>();
|
||||
|
||||
for (const triple of allTriples.triples || []) {
|
||||
const subject = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const value = getTermValue(triple.o);
|
||||
|
||||
if (!metadata.has(subject)) {
|
||||
metadata.set(subject, {});
|
||||
}
|
||||
const meta = metadata.get(subject)!;
|
||||
|
||||
if (predicate === RDFS_LABEL) {
|
||||
meta.label = value;
|
||||
} else if (predicate === RDFS_DOMAIN) {
|
||||
meta.domain = value;
|
||||
} else if (predicate === RDFS_RANGE) {
|
||||
meta.range = value;
|
||||
} else if (predicate === RDFS_COMMENT) {
|
||||
meta.comment = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Build classes list
|
||||
const classes: OntologyClass[] = [];
|
||||
for (const triple of classTriples.triples || []) {
|
||||
const uri = getTermValue(triple.s);
|
||||
const meta = metadata.get(uri) || {};
|
||||
classes.push({
|
||||
uri,
|
||||
label: meta.label || getLocalName(uri),
|
||||
comment: meta.comment,
|
||||
});
|
||||
}
|
||||
|
||||
// Build object properties list
|
||||
const objectProperties: OntologyProperty[] = [];
|
||||
const objectPropertyUris = new Set<string>();
|
||||
for (const triple of objectPropertyTriples.triples || []) {
|
||||
const uri = getTermValue(triple.s);
|
||||
const meta = metadata.get(uri) || {};
|
||||
objectPropertyUris.add(uri);
|
||||
objectProperties.push({
|
||||
uri,
|
||||
label: meta.label || getLocalName(uri),
|
||||
domain: meta.domain,
|
||||
range: meta.range,
|
||||
});
|
||||
}
|
||||
|
||||
// Build datatype properties list
|
||||
const datatypeProperties: OntologyProperty[] = [];
|
||||
const datatypePropertyUris = new Set<string>();
|
||||
for (const triple of datatypePropertyTriples.triples || []) {
|
||||
const uri = getTermValue(triple.s);
|
||||
const meta = metadata.get(uri) || {};
|
||||
datatypePropertyUris.add(uri);
|
||||
datatypeProperties.push({
|
||||
uri,
|
||||
label: meta.label || getLocalName(uri),
|
||||
domain: meta.domain,
|
||||
range: meta.range,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
classes,
|
||||
objectProperties,
|
||||
datatypeProperties,
|
||||
objectPropertyUris,
|
||||
datatypePropertyUris,
|
||||
};
|
||||
}, [
|
||||
isLoading,
|
||||
classTriples.triples,
|
||||
objectPropertyTriples.triples,
|
||||
datatypePropertyTriples.triples,
|
||||
allTriples.triples,
|
||||
]);
|
||||
|
||||
return {
|
||||
schema,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// Primary palette (migrated from useGraphData.ts)
|
||||
export const palette = {
|
||||
emerald: "#6EE7B7",
|
||||
pink: "#F9A8D4",
|
||||
blue: "#93C5FD",
|
||||
amber: "#FCD34D",
|
||||
purple: "#C4B5FD",
|
||||
rose: "#FDA4AF",
|
||||
cyan: "#67E8F9",
|
||||
red: "#FCA5A5",
|
||||
orange: "#F97316",
|
||||
};
|
||||
|
||||
// Semantic colors
|
||||
export const semantic = {
|
||||
success: palette.emerald,
|
||||
error: "#f66",
|
||||
warning: palette.orange,
|
||||
info: palette.blue,
|
||||
thinking: palette.blue,
|
||||
observation: palette.purple,
|
||||
answer: palette.emerald,
|
||||
user: palette.amber,
|
||||
};
|
||||
|
||||
// Text colors (dark theme)
|
||||
export const text = {
|
||||
primary: "#ddd",
|
||||
secondary: "#bbb",
|
||||
muted: "#aaa",
|
||||
subtle: "#888",
|
||||
faint: "#666",
|
||||
disabled: "#555",
|
||||
hint: "#444",
|
||||
};
|
||||
|
||||
// Surface/background colors
|
||||
export const surface = {
|
||||
base: "#0A0A0F",
|
||||
overlay: "rgba(15,15,20,0.95)",
|
||||
overlayLight: "rgba(15,15,20,0.8)",
|
||||
card: "rgba(255,255,255,0.02)",
|
||||
cardHover: "rgba(255,255,255,0.04)",
|
||||
};
|
||||
|
||||
// Border colors
|
||||
export const border = {
|
||||
subtle: "rgba(255,255,255,0.04)",
|
||||
default: "rgba(255,255,255,0.06)",
|
||||
medium: "rgba(255,255,255,0.1)",
|
||||
grid: "rgba(255,255,255,0.015)",
|
||||
};
|
||||
|
||||
// Helper: Generate glow color from hex
|
||||
export function withGlow(hex: string, opacity = 0.4): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${opacity})`;
|
||||
}
|
||||
|
||||
// Domain color palette (array for cycling)
|
||||
export const domainColors = [
|
||||
{ color: palette.emerald, glow: withGlow(palette.emerald) },
|
||||
{ color: palette.pink, glow: withGlow(palette.pink) },
|
||||
{ color: palette.blue, glow: withGlow(palette.blue) },
|
||||
{ color: palette.amber, glow: withGlow(palette.amber) },
|
||||
{ color: palette.purple, glow: withGlow(palette.purple) },
|
||||
{ color: palette.rose, glow: withGlow(palette.rose) },
|
||||
{ color: palette.cyan, glow: withGlow(palette.cyan) },
|
||||
{ color: palette.red, glow: withGlow(palette.red) },
|
||||
];
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "./colors";
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
// ── Domain Types ─────────────────────────────────────────────────
|
||||
export type DomainKey = string;
|
||||
|
||||
export interface EntityProps {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export interface Subclass {
|
||||
id: string;
|
||||
uri?: string;
|
||||
label: string;
|
||||
props: EntityProps;
|
||||
}
|
||||
|
||||
export interface OntologyDomain {
|
||||
label: string;
|
||||
color: string;
|
||||
glow: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
properties: string[];
|
||||
subclasses: Subclass[];
|
||||
}
|
||||
|
||||
export type OntologyType = Record<DomainKey, OntologyDomain>;
|
||||
|
||||
// ── Relationship Types ───────────────────────────────────────────
|
||||
export interface Relationship {
|
||||
from: string;
|
||||
to: string;
|
||||
predicate: string;
|
||||
strength?: number;
|
||||
domain: [DomainKey, DomainKey];
|
||||
}
|
||||
|
||||
// ── Query Types ──────────────────────────────────────────────────
|
||||
export interface DemoQuery {
|
||||
q: string;
|
||||
thinking: string[];
|
||||
answer: string;
|
||||
entities: string[];
|
||||
triples: number;
|
||||
}
|
||||
|
||||
// ── Entity Types ─────────────────────────────────────────────────
|
||||
export interface Entity extends Subclass {
|
||||
domain: DomainKey;
|
||||
color: string;
|
||||
glow: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface GraphNode extends Entity {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
r: number;
|
||||
}
|
||||
|
||||
// ── UI State Types ───────────────────────────────────────────────
|
||||
export type TabKey = "graph" | "query" | "explain" | "ontology" | "data";
|
||||
export type QueryPhase = "idle" | "thinking" | "answering" | "done";
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { getLocalName } from "./uri";
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/**
|
||||
* Extract the local name from a URI by taking the fragment after # or the last path segment
|
||||
*/
|
||||
export function getLocalName(uri: string): string {
|
||||
const hashIndex = uri.lastIndexOf("#");
|
||||
const slashIndex = uri.lastIndexOf("/");
|
||||
const index = Math.max(hashIndex, slashIndex);
|
||||
return index >= 0 ? uri.substring(index + 1) : uri;
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -1,843 +0,0 @@
|
|||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRUSTGRAPH RETAIL INTELLIGENCE DEMO
|
||||
// Ontology-Driven Context Graph: Consumer × Agent × Retail × Brand
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── Ontology Schema ──────────────────────────────────────────────
|
||||
const ONTOLOGY = {
|
||||
consumer: {
|
||||
label: "Consumer",
|
||||
color: "#6EE7B7",
|
||||
glow: "rgba(110,231,183,0.4)",
|
||||
icon: "👤",
|
||||
description: "Individuals and segments interacting with brands through retail channels",
|
||||
properties: ["segment", "preferences", "journeyStage", "lifetime_value", "sentiment"],
|
||||
subclasses: [
|
||||
{ id: "cs1", label: "Urban Millennials", props: { size: "2.4M", avgSpend: "$142/mo", loyalty: 0.78, journeyStage: "Engaged" } },
|
||||
{ id: "cs2", label: "Active Families", props: { size: "1.8M", avgSpend: "$218/mo", loyalty: 0.85, journeyStage: "Loyal" } },
|
||||
{ id: "cs3", label: "Eco-Conscious Gen Z", props: { size: "3.1M", avgSpend: "$96/mo", loyalty: 0.62, journeyStage: "Exploring" } },
|
||||
{ id: "cs4", label: "Luxury Seekers", props: { size: "890K", avgSpend: "$384/mo", loyalty: 0.91, journeyStage: "Advocate" } },
|
||||
{ id: "cs5", label: "Weekend Warriors", props: { size: "1.5M", avgSpend: "$167/mo", loyalty: 0.73, journeyStage: "Engaged" } },
|
||||
],
|
||||
},
|
||||
brand: {
|
||||
label: "Brand",
|
||||
color: "#F9A8D4",
|
||||
glow: "rgba(249,168,212,0.4)",
|
||||
icon: "✦",
|
||||
description: "Product brands seeking to connect with consumers through retail experiences",
|
||||
properties: ["identity", "positioning", "campaigns", "products", "partnerships"],
|
||||
subclasses: [
|
||||
{ id: "br1", label: "Lumière Beauty", props: { category: "Cosmetics", positioning: "Premium", campaigns: 12, sentiment: 0.87 } },
|
||||
{ id: "br2", label: "Nordic Trail", props: { category: "Outdoor Apparel", positioning: "Sustainable", campaigns: 8, sentiment: 0.82 } },
|
||||
{ id: "br3", label: "Velo Sport", props: { category: "Athletics", positioning: "Performance", campaigns: 15, sentiment: 0.79 } },
|
||||
{ id: "br4", label: "Casa Verde", props: { category: "Home & Living", positioning: "Artisanal", campaigns: 6, sentiment: 0.90 } },
|
||||
{ id: "br5", label: "Artisan Coffee Co.", props: { category: "F&B", positioning: "Community", campaigns: 10, sentiment: 0.85 } },
|
||||
],
|
||||
},
|
||||
retail: {
|
||||
label: "Retail",
|
||||
color: "#93C5FD",
|
||||
glow: "rgba(147,197,253,0.4)",
|
||||
icon: "🏬",
|
||||
description: "Channels, touchpoints, and experiences where brands meet consumers",
|
||||
properties: ["channel", "location", "traffic", "conversionRate", "experience_score"],
|
||||
subclasses: [
|
||||
{ id: "rt1", label: "Flagship Store NYC", props: { channel: "Physical", traffic: "48K/mo", conversion: "12.3%", experience: 0.91 } },
|
||||
{ id: "rt2", label: "Mobile Commerce App", props: { channel: "Digital", traffic: "1.2M/mo", conversion: "4.7%", experience: 0.78 } },
|
||||
{ id: "rt3", label: "Pop-Up Experience", props: { channel: "Experiential", traffic: "8K/event", conversion: "18.6%", experience: 0.95 } },
|
||||
{ id: "rt4", label: "Social Commerce", props: { channel: "Social", traffic: "890K/mo", conversion: "3.2%", experience: 0.72 } },
|
||||
{ id: "rt5", label: "Loyalty Hub", props: { channel: "Omnichannel", traffic: "340K/mo", conversion: "22.1%", experience: 0.88 } },
|
||||
],
|
||||
},
|
||||
agent: {
|
||||
label: "Agent",
|
||||
color: "#FCD34D",
|
||||
glow: "rgba(252,211,77,0.4)",
|
||||
icon: "⚡",
|
||||
description: "AI agents that orchestrate personalized brand-consumer connections",
|
||||
properties: ["capability", "contextSources", "accuracy", "latency", "decisions_per_day"],
|
||||
subclasses: [
|
||||
{ id: "ag1", label: "Recommendation Agent", props: { capability: "Product Discovery", accuracy: "94.2%", latency: "120ms", decisions: "2.1M/day" } },
|
||||
{ id: "ag2", label: "Personalization Agent", props: { capability: "Experience Tailoring", accuracy: "91.8%", latency: "85ms", decisions: "890K/day" } },
|
||||
{ id: "ag3", label: "Campaign Orchestrator", props: { capability: "Brand Activation", accuracy: "88.5%", latency: "200ms", decisions: "340K/day" } },
|
||||
{ id: "ag4", label: "Sentiment Analyst", props: { capability: "Brand Perception", accuracy: "96.1%", latency: "150ms", decisions: "1.5M/day" } },
|
||||
{ id: "ag5", label: "Journey Optimizer", props: { capability: "Path Optimization", accuracy: "89.7%", latency: "180ms", decisions: "560K/day" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Ontology Relationships (Triples) ──────────────────────────────
|
||||
const RELATIONSHIPS = [
|
||||
// Consumer ↔ Brand
|
||||
{ from: "cs1", to: "br1", predicate: "has_affinity_for", strength: 0.85, domain: ["consumer", "brand"] },
|
||||
{ from: "cs1", to: "br5", predicate: "frequents", strength: 0.69, domain: ["consumer", "brand"] },
|
||||
{ from: "cs2", to: "br2", predicate: "has_affinity_for", strength: 0.78, domain: ["consumer", "brand"] },
|
||||
{ from: "cs2", to: "br3", predicate: "purchases_from", strength: 0.88, domain: ["consumer", "brand"] },
|
||||
{ from: "cs3", to: "br2", predicate: "advocates_for", strength: 0.71, domain: ["consumer", "brand"] },
|
||||
{ from: "cs3", to: "br4", predicate: "has_affinity_for", strength: 0.65, domain: ["consumer", "brand"] },
|
||||
{ from: "cs3", to: "br5", predicate: "frequents", strength: 0.58, domain: ["consumer", "brand"] },
|
||||
{ from: "cs4", to: "br1", predicate: "loyal_to", strength: 0.92, domain: ["consumer", "brand"] },
|
||||
{ from: "cs4", to: "br4", predicate: "purchases_from", strength: 0.82, domain: ["consumer", "brand"] },
|
||||
{ from: "cs5", to: "br3", predicate: "has_affinity_for", strength: 0.76, domain: ["consumer", "brand"] },
|
||||
{ from: "cs5", to: "br5", predicate: "frequents", strength: 0.74, domain: ["consumer", "brand"] },
|
||||
// Consumer ↔ Retail
|
||||
{ from: "cs1", to: "rt2", predicate: "shops_via", strength: 0.82, domain: ["consumer", "retail"] },
|
||||
{ from: "cs1", to: "rt4", predicate: "discovers_through", strength: 0.71, domain: ["consumer", "retail"] },
|
||||
{ from: "cs2", to: "rt1", predicate: "shops_via", strength: 0.85, domain: ["consumer", "retail"] },
|
||||
{ from: "cs2", to: "rt5", predicate: "member_of", strength: 0.90, domain: ["consumer", "retail"] },
|
||||
{ from: "cs3", to: "rt3", predicate: "experiences", strength: 0.88, domain: ["consumer", "retail"] },
|
||||
{ from: "cs3", to: "rt4", predicate: "discovers_through", strength: 0.79, domain: ["consumer", "retail"] },
|
||||
{ from: "cs4", to: "rt1", predicate: "shops_via", strength: 0.94, domain: ["consumer", "retail"] },
|
||||
{ from: "cs4", to: "rt3", predicate: "experiences", strength: 0.86, domain: ["consumer", "retail"] },
|
||||
{ from: "cs5", to: "rt2", predicate: "shops_via", strength: 0.72, domain: ["consumer", "retail"] },
|
||||
{ from: "cs5", to: "rt5", predicate: "member_of", strength: 0.68, domain: ["consumer", "retail"] },
|
||||
// Brand ↔ Retail
|
||||
{ from: "br1", to: "rt1", predicate: "merchandises_in", strength: 0.90, domain: ["brand", "retail"] },
|
||||
{ from: "br1", to: "rt3", predicate: "activates_via", strength: 0.85, domain: ["brand", "retail"] },
|
||||
{ from: "br2", to: "rt3", predicate: "activates_via", strength: 0.82, domain: ["brand", "retail"] },
|
||||
{ from: "br2", to: "rt4", predicate: "promotes_on", strength: 0.75, domain: ["brand", "retail"] },
|
||||
{ from: "br3", to: "rt2", predicate: "sells_through", strength: 0.80, domain: ["brand", "retail"] },
|
||||
{ from: "br3", to: "rt5", predicate: "rewards_via", strength: 0.77, domain: ["brand", "retail"] },
|
||||
{ from: "br4", to: "rt1", predicate: "merchandises_in", strength: 0.88, domain: ["brand", "retail"] },
|
||||
{ from: "br4", to: "rt4", predicate: "promotes_on", strength: 0.70, domain: ["brand", "retail"] },
|
||||
{ from: "br5", to: "rt3", predicate: "activates_via", strength: 0.79, domain: ["brand", "retail"] },
|
||||
{ from: "br5", to: "rt5", predicate: "rewards_via", strength: 0.83, domain: ["brand", "retail"] },
|
||||
// Agent ↔ Consumer
|
||||
{ from: "ag1", to: "cs1", predicate: "recommends_to", strength: 0.87, domain: ["agent", "consumer"] },
|
||||
{ from: "ag1", to: "cs3", predicate: "recommends_to", strength: 0.81, domain: ["agent", "consumer"] },
|
||||
{ from: "ag2", to: "cs4", predicate: "personalizes_for", strength: 0.93, domain: ["agent", "consumer"] },
|
||||
{ from: "ag2", to: "cs2", predicate: "personalizes_for", strength: 0.84, domain: ["agent", "consumer"] },
|
||||
{ from: "ag4", to: "cs1", predicate: "monitors_sentiment_of", strength: 0.78, domain: ["agent", "consumer"] },
|
||||
{ from: "ag4", to: "cs3", predicate: "monitors_sentiment_of", strength: 0.82, domain: ["agent", "consumer"] },
|
||||
{ from: "ag5", to: "cs2", predicate: "optimizes_journey_for", strength: 0.86, domain: ["agent", "consumer"] },
|
||||
{ from: "ag5", to: "cs5", predicate: "optimizes_journey_for", strength: 0.75, domain: ["agent", "consumer"] },
|
||||
// Agent ↔ Brand
|
||||
{ from: "ag3", to: "br1", predicate: "orchestrates_campaign_for", strength: 0.88, domain: ["agent", "brand"] },
|
||||
{ from: "ag3", to: "br2", predicate: "orchestrates_campaign_for", strength: 0.82, domain: ["agent", "brand"] },
|
||||
{ from: "ag3", to: "br5", predicate: "orchestrates_campaign_for", strength: 0.79, domain: ["agent", "brand"] },
|
||||
{ from: "ag4", to: "br1", predicate: "analyzes_perception_of", strength: 0.91, domain: ["agent", "brand"] },
|
||||
{ from: "ag4", to: "br3", predicate: "analyzes_perception_of", strength: 0.85, domain: ["agent", "brand"] },
|
||||
{ from: "ag1", to: "br3", predicate: "curates_products_for", strength: 0.83, domain: ["agent", "brand"] },
|
||||
{ from: "ag1", to: "br4", predicate: "curates_products_for", strength: 0.77, domain: ["agent", "brand"] },
|
||||
// Agent ↔ Retail
|
||||
{ from: "ag2", to: "rt1", predicate: "tailors_experience_at", strength: 0.89, domain: ["agent", "retail"] },
|
||||
{ from: "ag2", to: "rt2", predicate: "tailors_experience_at", strength: 0.85, domain: ["agent", "retail"] },
|
||||
{ from: "ag3", to: "rt3", predicate: "deploys_campaign_at", strength: 0.81, domain: ["agent", "retail"] },
|
||||
{ from: "ag3", to: "rt4", predicate: "deploys_campaign_at", strength: 0.86, domain: ["agent", "retail"] },
|
||||
{ from: "ag5", to: "rt1", predicate: "optimizes_flow_at", strength: 0.84, domain: ["agent", "retail"] },
|
||||
{ from: "ag5", to: "rt5", predicate: "optimizes_flow_at", strength: 0.80, domain: ["agent", "retail"] },
|
||||
];
|
||||
|
||||
// ── Pre-built Agent Queries & Responses ────────────────────────────
|
||||
const DEMO_QUERIES = [
|
||||
{
|
||||
q: "Which brands should activate at the Pop-Up Experience to reach Eco-Conscious Gen Z?",
|
||||
thinking: [
|
||||
"Traversing ontology: Consumer[cs3] → has_affinity_for → Brand[br2, br4, br5]",
|
||||
"Traversing ontology: Consumer[cs3] → experiences → Retail[rt3]",
|
||||
"Cross-referencing: Brand activations at Retail[rt3]",
|
||||
"Ranking by: affinity strength × activation fit × conversion potential",
|
||||
],
|
||||
answer: "Nordic Trail and Artisan Coffee Co. are the strongest activation candidates for the Pop-Up Experience targeting Eco-Conscious Gen Z. Nordic Trail's sustainability positioning aligns with this segment's values (affinity: 0.71) and already activates via experiential retail (strength: 0.82). Artisan Coffee Co. has existing frequency with this segment (0.58) and pop-up activation experience (0.79). Casa Verde is a secondary candidate — lower affinity (0.65) but high experiential fit.",
|
||||
entities: ["cs3", "br2", "br5", "br4", "rt3"],
|
||||
triples: 8,
|
||||
},
|
||||
{
|
||||
q: "How should Lumière Beauty optimize its engagement with Luxury Seekers across channels?",
|
||||
thinking: [
|
||||
"Resolving entities: Brand[br1] = Lumière Beauty, Consumer[cs4] = Luxury Seekers",
|
||||
"Traversing: Brand[br1] → merchandises_in → Retail[rt1], activates_via → Retail[rt3]",
|
||||
"Traversing: Consumer[cs4] → shops_via → Retail[rt1], experiences → Retail[rt3]",
|
||||
"Agent context: Personalization Agent[ag2] → personalizes_for → Consumer[cs4] (0.93)",
|
||||
"Aggregating channel overlap and engagement signals",
|
||||
],
|
||||
answer: "Lumière Beauty has exceptional alignment with Luxury Seekers — the strongest brand-consumer affinity in the graph (0.92). Both converge on Flagship Store NYC and Pop-Up Experiences. The Personalization Agent already achieves 93% accuracy for this segment. Recommendation: Deepen the flagship in-store experience with agent-powered personalized consultations, and create exclusive pop-up previews. The loyalty-to-advocacy pipeline is strong (journey stage: Advocate) — leverage this for referral programs through the Loyalty Hub.",
|
||||
entities: ["br1", "cs4", "rt1", "rt3", "ag2", "rt5"],
|
||||
triples: 12,
|
||||
},
|
||||
{
|
||||
q: "What's the optimal agent configuration for the Mobile Commerce channel?",
|
||||
thinking: [
|
||||
"Resolving: Retail[rt2] = Mobile Commerce App",
|
||||
"Traversing: Agent → tailors_experience_at → Retail[rt2]",
|
||||
"Traversing: Consumer → shops_via → Retail[rt2]: [cs1, cs5]",
|
||||
"Traversing: Brand → sells_through → Retail[rt2]: [br3]",
|
||||
"Evaluating agent capabilities against mobile channel requirements",
|
||||
],
|
||||
answer: "Mobile Commerce currently has the Personalization Agent deployed (experience tailoring, 85% strength). Primary consumer segments are Urban Millennials (0.82) and Weekend Warriors (0.72). Add the Recommendation Agent — it already serves Urban Millennials (0.87) and can curate Velo Sport products (the channel's primary brand). The Journey Optimizer should be connected to reduce the gap between the channel's high traffic (1.2M/mo) and moderate conversion (4.7%). Projected improvement: 2.1% conversion lift through graph-informed product sequencing.",
|
||||
entities: ["rt2", "ag2", "ag1", "ag5", "cs1", "cs5", "br3"],
|
||||
triples: 11,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Helper: find all entities ────────────────────────────────────────
|
||||
function getAllEntities() {
|
||||
const all = [];
|
||||
Object.entries(ONTOLOGY).forEach(([domain, data]) => {
|
||||
data.subclasses.forEach((sc) => {
|
||||
all.push({ ...sc, domain, color: data.color, glow: data.glow, icon: data.icon });
|
||||
});
|
||||
});
|
||||
return all;
|
||||
}
|
||||
|
||||
// ── Graph Visualization (Canvas-based force layout) ─────────────────
|
||||
function GraphCanvas({ highlightedEntities, onNodeClick, activeFilter }) {
|
||||
const canvasRef = useRef(null);
|
||||
const nodesRef = useRef([]);
|
||||
const animRef = useRef(null);
|
||||
const hoveredRef = useRef(null);
|
||||
const [hovered, setHovered] = useState(null);
|
||||
|
||||
const entities = useMemo(() => getAllEntities(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
canvas.width = rect.width * 2;
|
||||
canvas.height = rect.height * 2;
|
||||
canvas.style.width = rect.width + "px";
|
||||
canvas.style.height = rect.height + "px";
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
|
||||
// Position nodes in domain clusters
|
||||
const domainPositions = {
|
||||
consumer: { x: cx - cx * 0.35, y: cy - cy * 0.32 },
|
||||
brand: { x: cx + cx * 0.35, y: cy - cy * 0.32 },
|
||||
retail: { x: cx + cx * 0.35, y: cy + cy * 0.32 },
|
||||
agent: { x: cx - cx * 0.35, y: cy + cy * 0.32 },
|
||||
};
|
||||
|
||||
nodesRef.current = entities.map((e, i) => {
|
||||
const dp = domainPositions[e.domain];
|
||||
const subIdx = ONTOLOGY[e.domain].subclasses.findIndex((s) => s.id === e.id);
|
||||
const total = ONTOLOGY[e.domain].subclasses.length;
|
||||
const angle = ((Math.PI * 2) / total) * subIdx - Math.PI / 2;
|
||||
const radius = Math.min(canvas.width, canvas.height) * 0.1;
|
||||
return {
|
||||
...e,
|
||||
x: dp.x + Math.cos(angle) * radius,
|
||||
y: dp.y + Math.sin(angle) * radius,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
targetX: dp.x + Math.cos(angle) * radius,
|
||||
targetY: dp.y + Math.sin(angle) * radius,
|
||||
r: 18,
|
||||
};
|
||||
});
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
let time = 0;
|
||||
|
||||
function draw() {
|
||||
time += 0.005;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Subtle grid
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.015)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x < canvas.width; x += 60) {
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < canvas.height; y += 60) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Domain labels
|
||||
Object.entries(domainPositions).forEach(([domain, pos]) => {
|
||||
const data = ONTOLOGY[domain];
|
||||
ctx.font = "bold 22px 'IBM Plex Mono', monospace";
|
||||
ctx.fillStyle = data.color + "44";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(data.label.toUpperCase(), pos.x, pos.y - Math.min(canvas.width, canvas.height) * 0.14);
|
||||
});
|
||||
|
||||
// Draw edges
|
||||
const nodes = nodesRef.current;
|
||||
const filteredRels = activeFilter
|
||||
? RELATIONSHIPS.filter((r) => r.domain.includes(activeFilter))
|
||||
: RELATIONSHIPS;
|
||||
|
||||
filteredRels.forEach((rel) => {
|
||||
const fromNode = nodes.find((n) => n.id === rel.from);
|
||||
const toNode = nodes.find((n) => n.id === rel.to);
|
||||
if (!fromNode || !toNode) return;
|
||||
|
||||
const isHighlighted =
|
||||
highlightedEntities &&
|
||||
highlightedEntities.includes(rel.from) &&
|
||||
highlightedEntities.includes(rel.to);
|
||||
|
||||
const baseAlpha = isHighlighted ? 0.7 : 0.12;
|
||||
const pulse = isHighlighted ? Math.sin(time * 4) * 0.15 + 0.15 : 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(fromNode.x, fromNode.y);
|
||||
// Curved edges
|
||||
const mx = (fromNode.x + toNode.x) / 2 + (fromNode.y - toNode.y) * 0.1;
|
||||
const my = (fromNode.y + toNode.y) / 2 + (toNode.x - fromNode.x) * 0.1;
|
||||
ctx.quadraticCurveTo(mx, my, toNode.x, toNode.y);
|
||||
|
||||
const gradient = ctx.createLinearGradient(fromNode.x, fromNode.y, toNode.x, toNode.y);
|
||||
gradient.addColorStop(0, fromNode.color + Math.round((baseAlpha + pulse) * 255).toString(16).padStart(2, "0"));
|
||||
gradient.addColorStop(1, toNode.color + Math.round((baseAlpha + pulse) * 255).toString(16).padStart(2, "0"));
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.lineWidth = isHighlighted ? 3 : 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Animated particles on highlighted edges
|
||||
if (isHighlighted) {
|
||||
const t = (time * 2 + rel.strength) % 1;
|
||||
const px = (1 - t) * (1 - t) * fromNode.x + 2 * (1 - t) * t * mx + t * t * toNode.x;
|
||||
const py = (1 - t) * (1 - t) * fromNode.y + 2 * (1 - t) * t * my + t * t * toNode.y;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
// Draw nodes
|
||||
nodes.forEach((node) => {
|
||||
const isHighlighted = highlightedEntities && highlightedEntities.includes(node.id);
|
||||
const isHovered = hoveredRef.current === node.id;
|
||||
const isDimmed = highlightedEntities && highlightedEntities.length > 0 && !isHighlighted;
|
||||
const isFiltered = activeFilter && node.domain !== activeFilter && !RELATIONSHIPS.some(
|
||||
r => r.domain.includes(activeFilter) && (r.from === node.id || r.to === node.id)
|
||||
);
|
||||
|
||||
const alpha = isFiltered ? 0.15 : isDimmed ? 0.3 : 1;
|
||||
const r = isHighlighted || isHovered ? node.r * 1.4 : node.r;
|
||||
const pulseR = isHighlighted ? Math.sin(time * 3) * 3 : 0;
|
||||
|
||||
// Glow
|
||||
if ((isHighlighted || isHovered) && !isFiltered) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, r + 12 + pulseR, 0, Math.PI * 2);
|
||||
const grd = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 12 + pulseR);
|
||||
grd.addColorStop(0, node.glow);
|
||||
grd.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color + Math.round(alpha * 255 * 0.2).toString(16).padStart(2, "0");
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = node.color + Math.round(alpha * 255).toString(16).padStart(2, "0");
|
||||
ctx.lineWidth = isHighlighted ? 2.5 : 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
ctx.font = `${isHighlighted ? "bold " : ""}${isHovered ? 17 : 14}px 'IBM Plex Sans', sans-serif`;
|
||||
ctx.fillStyle = `rgba(255,255,255,${alpha * (isHighlighted ? 1 : 0.75)})`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(node.label, node.x, node.y + r + 18);
|
||||
|
||||
// Spring physics
|
||||
node.x += (node.targetX - node.x) * 0.02;
|
||||
node.y += (node.targetY - node.y) * 0.02;
|
||||
node.x += Math.sin(time + node.targetX * 0.01) * 0.3;
|
||||
node.y += Math.cos(time + node.targetY * 0.01) * 0.3;
|
||||
});
|
||||
|
||||
animRef.current = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
draw();
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [entities, highlightedEntities, activeFilter]);
|
||||
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) * 2;
|
||||
const y = (e.clientY - rect.top) * 2;
|
||||
const nodes = nodesRef.current;
|
||||
let found = null;
|
||||
for (const node of nodes) {
|
||||
const dx = node.x - x;
|
||||
const dy = node.y - y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < node.r * 1.5) {
|
||||
found = node.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
hoveredRef.current = found;
|
||||
setHovered(found);
|
||||
canvas.style.cursor = found ? "pointer" : "default";
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((e) => {
|
||||
if (hoveredRef.current && onNodeClick) {
|
||||
const node = nodesRef.current.find((n) => n.id === hoveredRef.current);
|
||||
if (node) onNodeClick(node);
|
||||
}
|
||||
}, [onNodeClick]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onClick={handleClick}
|
||||
style={{ display: "block", width: "100%", height: "100%" }}
|
||||
/>
|
||||
{hovered && (() => {
|
||||
const node = nodesRef.current.find((n) => n.id === hovered);
|
||||
if (!node) return null;
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const sx = node.x / 2;
|
||||
const sy = node.y / 2;
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute", left: sx + 20, top: sy - 20,
|
||||
background: "rgba(15,15,20,0.95)", border: `1px solid ${node.color}44`,
|
||||
borderRadius: 8, padding: "10px 14px", pointerEvents: "none",
|
||||
backdropFilter: "blur(12px)", zIndex: 10, minWidth: 180,
|
||||
}}>
|
||||
<div style={{ color: node.color, fontWeight: 700, fontSize: 13, fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{node.icon} {node.label}
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 11, marginTop: 4, fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{Object.entries(node.props || {}).map(([k, v]) => (
|
||||
<div key={k}><span style={{ color: "#666" }}>{k}:</span> <span style={{ color: "#ccc" }}>{String(v)}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Typewriter Effect ────────────────────────────────────────────────
|
||||
function Typewriter({ text, speed = 12, onDone }) {
|
||||
const [displayed, setDisplayed] = useState("");
|
||||
const idx = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
idx.current = 0;
|
||||
setDisplayed("");
|
||||
const interval = setInterval(() => {
|
||||
idx.current++;
|
||||
if (idx.current >= text.length) {
|
||||
setDisplayed(text);
|
||||
clearInterval(interval);
|
||||
onDone && onDone();
|
||||
} else {
|
||||
setDisplayed(text.slice(0, idx.current));
|
||||
}
|
||||
}, speed);
|
||||
return () => clearInterval(interval);
|
||||
}, [text, speed]);
|
||||
|
||||
return <span>{displayed}<span style={{ opacity: displayed.length < text.length ? 1 : 0, color: "#FCD34D" }}>▌</span></span>;
|
||||
}
|
||||
|
||||
// ── Main App ─────────────────────────────────────────────────────────
|
||||
export default function TrustGraphRetailDemo() {
|
||||
const [activeTab, setActiveTab] = useState("graph");
|
||||
const [activeFilter, setActiveFilter] = useState(null);
|
||||
const [selectedQuery, setSelectedQuery] = useState(null);
|
||||
const [queryPhase, setQueryPhase] = useState("idle"); // idle, thinking, answering, done
|
||||
const [thinkingStep, setThinkingStep] = useState(0);
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [showOntology, setShowOntology] = useState(false);
|
||||
|
||||
const runQuery = (idx) => {
|
||||
setSelectedQuery(idx);
|
||||
setQueryPhase("thinking");
|
||||
setThinkingStep(0);
|
||||
const q = DEMO_QUERIES[idx];
|
||||
let step = 0;
|
||||
const interval = setInterval(() => {
|
||||
step++;
|
||||
if (step >= q.thinking.length) {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => setQueryPhase("answering"), 400);
|
||||
}
|
||||
setThinkingStep(step);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const highlightedEntities = selectedQuery !== null && queryPhase !== "idle"
|
||||
? DEMO_QUERIES[selectedQuery].entities
|
||||
: selectedNode
|
||||
? [selectedNode.id, ...RELATIONSHIPS.filter(r => r.from === selectedNode.id || r.to === selectedNode.id).map(r => r.from === selectedNode.id ? r.to : r.from)]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", minHeight: "100vh", background: "#0A0A0F",
|
||||
fontFamily: "'IBM Plex Sans', -apple-system, sans-serif",
|
||||
color: "#E5E5E5", overflow: "hidden",
|
||||
}}>
|
||||
{/* ── Header ────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
borderBottom: "1px solid rgba(255,255,255,0.06)",
|
||||
padding: "16px 28px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
background: "linear-gradient(180deg, rgba(15,15,22,1) 0%, rgba(10,10,15,1) 100%)",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
background: "linear-gradient(135deg, #6EE7B7 0%, #93C5FD 50%, #F9A8D4 100%)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 18, fontWeight: 900, color: "#0A0A0F",
|
||||
}}>TG</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, letterSpacing: "-0.02em", color: "#fff" }}>
|
||||
TrustGraph
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#666", fontFamily: "'IBM Plex Mono', monospace", letterSpacing: "0.05em" }}>
|
||||
RETAIL INTELLIGENCE PLATFORM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, fontFamily: "'IBM Plex Mono', monospace", fontSize: 12 }}>
|
||||
{["graph", "query", "ontology"].map((tab) => (
|
||||
<button key={tab} onClick={() => { setActiveTab(tab); if (tab !== "query") { setSelectedQuery(null); setQueryPhase("idle"); } }}
|
||||
style={{
|
||||
padding: "7px 16px", borderRadius: 6, border: "none", cursor: "pointer",
|
||||
background: activeTab === tab ? "rgba(255,255,255,0.1)" : "transparent",
|
||||
color: activeTab === tab ? "#fff" : "#666",
|
||||
fontFamily: "'IBM Plex Mono', monospace", fontSize: 12, fontWeight: activeTab === tab ? 600 : 400,
|
||||
transition: "all 0.2s",
|
||||
}}>
|
||||
{tab === "graph" ? "◈ Context Graph" : tab === "query" ? "⚡ Agent Query" : "◇ Ontology"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Domain Filter Bar ──────────────────────────────── */}
|
||||
{activeTab === "graph" && (
|
||||
<div style={{
|
||||
padding: "12px 28px", display: "flex", gap: 8, alignItems: "center",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.04)",
|
||||
}}>
|
||||
<span style={{ fontSize: 11, color: "#555", fontFamily: "'IBM Plex Mono', monospace", marginRight: 8 }}>FILTER:</span>
|
||||
<button onClick={() => setActiveFilter(null)}
|
||||
style={{
|
||||
padding: "5px 12px", borderRadius: 20, border: `1px solid ${!activeFilter ? '#fff' : 'rgba(255,255,255,0.1)'}`,
|
||||
background: !activeFilter ? "rgba(255,255,255,0.08)" : "transparent",
|
||||
color: !activeFilter ? "#fff" : "#777", fontSize: 11, cursor: "pointer",
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>All</button>
|
||||
{Object.entries(ONTOLOGY).map(([key, data]) => (
|
||||
<button key={key} onClick={() => setActiveFilter(activeFilter === key ? null : key)}
|
||||
style={{
|
||||
padding: "5px 12px", borderRadius: 20,
|
||||
border: `1px solid ${activeFilter === key ? data.color + '88' : 'rgba(255,255,255,0.1)'}`,
|
||||
background: activeFilter === key ? data.color + "15" : "transparent",
|
||||
color: activeFilter === key ? data.color : "#777",
|
||||
fontSize: 11, cursor: "pointer", fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
{data.icon} {data.label}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ marginLeft: "auto", fontSize: 11, color: "#444", fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{getAllEntities().length} entities · {RELATIONSHIPS.length} relationships
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main Content ──────────────────────────────────── */}
|
||||
<div style={{ display: "flex", height: "calc(100vh - 110px)" }}>
|
||||
|
||||
{/* ── Graph View ──────────────────────────────────── */}
|
||||
{activeTab === "graph" && (
|
||||
<>
|
||||
<div style={{ flex: 1, position: "relative" }}>
|
||||
<GraphCanvas
|
||||
highlightedEntities={highlightedEntities}
|
||||
onNodeClick={(node) => setSelectedNode(selectedNode?.id === node.id ? null : node)}
|
||||
activeFilter={activeFilter}
|
||||
/>
|
||||
</div>
|
||||
{/* Side panel for selected node */}
|
||||
{selectedNode && (
|
||||
<div style={{
|
||||
width: 320, borderLeft: "1px solid rgba(255,255,255,0.06)",
|
||||
background: "rgba(12,12,18,0.95)", padding: 24, overflowY: "auto",
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
|
||||
<div style={{ color: ONTOLOGY[selectedNode.domain].color, fontSize: 11, fontFamily: "'IBM Plex Mono', monospace", fontWeight: 600 }}>
|
||||
{ONTOLOGY[selectedNode.domain].label.toUpperCase()} ENTITY
|
||||
</div>
|
||||
<button onClick={() => setSelectedNode(null)} style={{ background: "none", border: "none", color: "#666", cursor: "pointer", fontSize: 18 }}>×</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#fff", marginBottom: 6 }}>
|
||||
{selectedNode.icon} {selectedNode.label}
|
||||
</div>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ fontSize: 10, color: "#555", fontFamily: "'IBM Plex Mono', monospace", marginBottom: 10, letterSpacing: "0.1em" }}>PROPERTIES</div>
|
||||
{Object.entries(selectedNode.props || {}).map(([k, v]) => (
|
||||
<div key={k} style={{ display: "flex", justifyContent: "space-between", padding: "8px 0", borderBottom: "1px solid rgba(255,255,255,0.04)" }}>
|
||||
<span style={{ fontSize: 12, color: "#888" }}>{k}</span>
|
||||
<span style={{ fontSize: 12, color: "#ddd", fontFamily: "'IBM Plex Mono', monospace" }}>{String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ fontSize: 10, color: "#555", fontFamily: "'IBM Plex Mono', monospace", marginBottom: 10, letterSpacing: "0.1em" }}>RELATIONSHIPS</div>
|
||||
{RELATIONSHIPS.filter(r => r.from === selectedNode.id || r.to === selectedNode.id).map((r, i) => {
|
||||
const otherId = r.from === selectedNode.id ? r.to : r.from;
|
||||
const other = getAllEntities().find(e => e.id === otherId);
|
||||
const direction = r.from === selectedNode.id ? "→" : "←";
|
||||
return (
|
||||
<div key={i} style={{
|
||||
padding: "8px 10px", marginBottom: 4, borderRadius: 6,
|
||||
background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.04)",
|
||||
cursor: "pointer",
|
||||
}} onClick={() => { const n = getAllEntities().find(e => e.id === otherId); if (n) setSelectedNode(n); }}>
|
||||
<div style={{ fontSize: 11, color: "#aaa" }}>
|
||||
<span style={{ color: other?.color || "#888" }}>{direction} {other?.label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "#666", fontFamily: "'IBM Plex Mono', monospace", marginTop: 2 }}>
|
||||
{r.predicate.replace(/_/g, " ")} · strength: {r.strength}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Agent Query View ────────────────────────────── */}
|
||||
{activeTab === "query" && (
|
||||
<div style={{ flex: 1, display: "flex" }}>
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
{/* Query selector */}
|
||||
<div style={{ padding: "20px 28px", borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
|
||||
<div style={{ fontSize: 10, color: "#555", fontFamily: "'IBM Plex Mono', monospace", marginBottom: 12, letterSpacing: "0.1em" }}>
|
||||
SELECT A QUERY TO SEE GRAPH-POWERED AGENT INTELLIGENCE
|
||||
</div>
|
||||
{DEMO_QUERIES.map((dq, idx) => (
|
||||
<button key={idx} onClick={() => runQuery(idx)}
|
||||
style={{
|
||||
display: "block", width: "100%", textAlign: "left",
|
||||
padding: "12px 16px", marginBottom: 8, borderRadius: 8,
|
||||
border: `1px solid ${selectedQuery === idx ? '#FCD34D33' : 'rgba(255,255,255,0.06)'}`,
|
||||
background: selectedQuery === idx ? "rgba(252,211,77,0.05)" : "rgba(255,255,255,0.02)",
|
||||
color: selectedQuery === idx ? "#FCD34D" : "#bbb",
|
||||
cursor: "pointer", fontSize: 13, lineHeight: 1.5,
|
||||
fontFamily: "'IBM Plex Sans', sans-serif",
|
||||
transition: "all 0.2s",
|
||||
}}>
|
||||
<span style={{ color: "#FCD34D88", fontFamily: "'IBM Plex Mono', monospace", fontSize: 11 }}>⚡ QUERY {idx + 1}</span><br />
|
||||
{dq.q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Response area */}
|
||||
{selectedQuery !== null && (
|
||||
<div style={{ flex: 1, padding: "24px 28px", overflowY: "auto" }}>
|
||||
{/* Graph traversal steps */}
|
||||
{(queryPhase === "thinking" || queryPhase === "answering" || queryPhase === "done") && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 10, color: "#FCD34D88", fontFamily: "'IBM Plex Mono', monospace", marginBottom: 12, letterSpacing: "0.1em" }}>
|
||||
◈ GRAPH TRAVERSAL
|
||||
</div>
|
||||
{DEMO_QUERIES[selectedQuery].thinking.map((step, i) => (
|
||||
<div key={i} style={{
|
||||
padding: "8px 12px", marginBottom: 4, borderRadius: 6,
|
||||
background: i < thinkingStep ? "rgba(252,211,77,0.04)" : "rgba(255,255,255,0.01)",
|
||||
borderLeft: `2px solid ${i < thinkingStep ? '#FCD34D44' : 'rgba(255,255,255,0.04)'}`,
|
||||
opacity: i < thinkingStep ? 1 : 0.3,
|
||||
transition: "all 0.4s",
|
||||
fontFamily: "'IBM Plex Mono', monospace", fontSize: 11, color: "#aaa",
|
||||
}}>
|
||||
{i < thinkingStep && <span style={{ color: "#6EE7B7", marginRight: 8 }}>✓</span>}
|
||||
{step}
|
||||
</div>
|
||||
))}
|
||||
{queryPhase === "thinking" && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: "#FCD34D66", fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
Traversing graph...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer */}
|
||||
{(queryPhase === "answering" || queryPhase === "done") && (
|
||||
<div style={{
|
||||
padding: 20, borderRadius: 10,
|
||||
background: "linear-gradient(135deg, rgba(252,211,77,0.04) 0%, rgba(110,231,183,0.04) 100%)",
|
||||
border: "1px solid rgba(252,211,77,0.12)",
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, alignItems: "center" }}>
|
||||
<div style={{ fontSize: 10, color: "#FCD34D88", fontFamily: "'IBM Plex Mono', monospace", letterSpacing: "0.1em" }}>
|
||||
AGENT RESPONSE
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "#555", fontFamily: "'IBM Plex Mono', monospace" }}>
|
||||
{DEMO_QUERIES[selectedQuery].triples} triples traversed · {DEMO_QUERIES[selectedQuery].entities.length} entities resolved
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.7, color: "#ddd" }}>
|
||||
<Typewriter
|
||||
text={DEMO_QUERIES[selectedQuery].answer}
|
||||
speed={10}
|
||||
onDone={() => setQueryPhase("done")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graph visualization alongside query */}
|
||||
<div style={{ width: "45%", borderLeft: "1px solid rgba(255,255,255,0.06)" }}>
|
||||
<GraphCanvas highlightedEntities={highlightedEntities} onNodeClick={() => {}} activeFilter={null} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Ontology View ──────────────────────────────── */}
|
||||
{activeTab === "ontology" && (
|
||||
<div style={{ flex: 1, padding: "28px", overflowY: "auto" }}>
|
||||
<div style={{ maxWidth: 900, margin: "0 auto" }}>
|
||||
<div style={{ fontSize: 10, color: "#555", fontFamily: "'IBM Plex Mono', monospace", letterSpacing: "0.1em", marginBottom: 24 }}>
|
||||
ONTOLOGY SCHEMA · RETAIL INTELLIGENCE DOMAIN
|
||||
</div>
|
||||
|
||||
{/* Ontology class cards */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 32 }}>
|
||||
{Object.entries(ONTOLOGY).map(([key, data]) => (
|
||||
<div key={key} style={{
|
||||
padding: 24, borderRadius: 12,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: `1px solid ${data.color}22`,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 24 }}>{data.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 18, color: data.color }}>{data.label}</div>
|
||||
<div style={{ fontSize: 11, color: "#666", fontFamily: "'IBM Plex Mono', monospace" }}>owl:Class</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888", lineHeight: 1.5, marginBottom: 14 }}>{data.description}</div>
|
||||
<div style={{ fontSize: 10, color: "#555", fontFamily: "'IBM Plex Mono', monospace", marginBottom: 8, letterSpacing: "0.05em" }}>PROPERTIES</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{data.properties.map((p) => (
|
||||
<span key={p} style={{
|
||||
padding: "3px 8px", borderRadius: 4, fontSize: 10,
|
||||
background: data.color + "10", color: data.color + "cc",
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
border: `1px solid ${data.color}22`,
|
||||
}}>{p}</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "#555", fontFamily: "'IBM Plex Mono', monospace", marginTop: 14, marginBottom: 8, letterSpacing: "0.05em" }}>
|
||||
INSTANCES ({data.subclasses.length})
|
||||
</div>
|
||||
{data.subclasses.map((sc) => (
|
||||
<div key={sc.id} style={{
|
||||
padding: "6px 10px", marginBottom: 3, borderRadius: 4,
|
||||
background: "rgba(255,255,255,0.02)", fontSize: 11, color: "#aaa",
|
||||
display: "flex", justifyContent: "space-between",
|
||||
}}>
|
||||
<span>{sc.label}</span>
|
||||
<span style={{ color: "#555", fontFamily: "'IBM Plex Mono', monospace", fontSize: 10 }}>{sc.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Relationship predicates */}
|
||||
<div style={{
|
||||
padding: 24, borderRadius: 12,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: "#555", fontFamily: "'IBM Plex Mono', monospace", letterSpacing: "0.1em", marginBottom: 16 }}>
|
||||
RELATIONSHIP PREDICATES
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||
{[...new Set(RELATIONSHIPS.map(r => r.predicate))].map((pred) => {
|
||||
const sample = RELATIONSHIPS.find(r => r.predicate === pred);
|
||||
const fromDomain = sample.domain[0];
|
||||
const toDomain = sample.domain[1];
|
||||
return (
|
||||
<div key={pred} style={{
|
||||
padding: "10px 12px", borderRadius: 6,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.04)",
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: "#ccc", fontFamily: "'IBM Plex Mono', monospace", marginBottom: 4 }}>
|
||||
{pred.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "#555" }}>
|
||||
<span style={{ color: ONTOLOGY[fromDomain].color }}>{ONTOLOGY[fromDomain].label}</span>
|
||||
{" → "}
|
||||
<span style={{ color: ONTOLOGY[toDomain].color }}>{ONTOLOGY[toDomain].label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Triple count summary */}
|
||||
<div style={{
|
||||
marginTop: 20, padding: "16px 24px", borderRadius: 10,
|
||||
background: "linear-gradient(135deg, rgba(110,231,183,0.04) 0%, rgba(147,197,253,0.04) 50%, rgba(249,168,212,0.04) 100%)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
display: "flex", justifyContent: "space-around",
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
{[
|
||||
{ label: "Classes", value: 4 },
|
||||
{ label: "Instances", value: getAllEntities().length },
|
||||
{ label: "Predicates", value: [...new Set(RELATIONSHIPS.map(r => r.predicate))].length },
|
||||
{ label: "Triples", value: RELATIONSHIPS.length },
|
||||
].map((s) => (
|
||||
<div key={s.label} style={{ textAlign: "center" }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: "#fff" }}>{s.value}</div>
|
||||
<div style={{ fontSize: 10, color: "#666", letterSpacing: "0.05em" }}>{s.label.toUpperCase()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Bottom Status Bar ──────────────────────────────── */}
|
||||
<div style={{
|
||||
position: "fixed", bottom: 0, left: 0, right: 0,
|
||||
padding: "8px 28px", borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
background: "rgba(10,10,15,0.95)", backdropFilter: "blur(8px)",
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, color: "#444",
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: 20 }}>
|
||||
<span>◈ Ontology: Consumer × Agent × Retail × Brand</span>
|
||||
<span>⬡ GraphRAG: Active</span>
|
||||
<span>⚡ Agent Orchestration: Online</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<span style={{ color: "#6EE7B7" }}>●</span> Context Graph Connected
|
||||
<span style={{ color: "#888" }}>|</span>
|
||||
<span>trustgraph.ai</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom', '@tanstack/react-query'],
|
||||
alias: {
|
||||
react: path.resolve('./node_modules/react'),
|
||||
'react-dom': path.resolve('./node_modules/react-dom'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api/socket": {
|
||||
// target: "wss://broker.app.trustgraph.ai/",
|
||||
target: "ws://localhost:8088/",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace("/api/socket", "/api/v1/socket"),
|
||||
},
|
||||
"/api/export-core": {
|
||||
target: "http://localhost:8088/",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (x) => x.replace("/api/export-core", "/api/v1/export-core"),
|
||||
},
|
||||
"/api/import-core": {
|
||||
target: "http://localhost:8088/",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (x) => x.replace("/api/import-core", "/api/v1/import-core"),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue