|
import { useEffect, useState } from 'react' |
|
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph' |
|
import Text from '@/components/ui/Text' |
|
import Button from '@/components/ui/Button' |
|
import useLightragGraph from '@/hooks/useLightragGraph' |
|
import { useTranslation } from 'react-i18next' |
|
import { GitBranchPlus, Scissors } from 'lucide-react' |
|
import EditablePropertyRow from './EditablePropertyRow' |
|
|
|
|
|
|
|
|
|
const PropertiesView = () => { |
|
const { getNode, getEdge } = useLightragGraph() |
|
const selectedNode = useGraphStore.use.selectedNode() |
|
const focusedNode = useGraphStore.use.focusedNode() |
|
const selectedEdge = useGraphStore.use.selectedEdge() |
|
const focusedEdge = useGraphStore.use.focusedEdge() |
|
const graphDataVersion = useGraphStore.use.graphDataVersion() |
|
|
|
const [currentElement, setCurrentElement] = useState<NodeType | EdgeType | null>(null) |
|
const [currentType, setCurrentType] = useState<'node' | 'edge' | null>(null) |
|
|
|
|
|
useEffect(() => { |
|
let type: 'node' | 'edge' | null = null |
|
let element: RawNodeType | RawEdgeType | null = null |
|
if (focusedNode) { |
|
type = 'node' |
|
element = getNode(focusedNode) |
|
} else if (selectedNode) { |
|
type = 'node' |
|
element = getNode(selectedNode) |
|
} else if (focusedEdge) { |
|
type = 'edge' |
|
element = getEdge(focusedEdge, true) |
|
} else if (selectedEdge) { |
|
type = 'edge' |
|
element = getEdge(selectedEdge, true) |
|
} |
|
|
|
if (element) { |
|
if (type == 'node') { |
|
setCurrentElement(refineNodeProperties(element as any)) |
|
} else { |
|
setCurrentElement(refineEdgeProperties(element as any)) |
|
} |
|
setCurrentType(type) |
|
} else { |
|
setCurrentElement(null) |
|
setCurrentType(null) |
|
} |
|
}, [ |
|
focusedNode, |
|
selectedNode, |
|
focusedEdge, |
|
selectedEdge, |
|
graphDataVersion, |
|
setCurrentElement, |
|
setCurrentType, |
|
getNode, |
|
getEdge |
|
]) |
|
|
|
if (!currentElement) { |
|
return <></> |
|
} |
|
return ( |
|
<div className="bg-background/80 max-w-xs rounded-lg border-2 p-2 text-xs backdrop-blur-lg"> |
|
{currentType == 'node' ? ( |
|
<NodePropertiesView node={currentElement as any} /> |
|
) : ( |
|
<EdgePropertiesView edge={currentElement as any} /> |
|
)} |
|
</div> |
|
) |
|
} |
|
|
|
type NodeType = RawNodeType & { |
|
relationships: { |
|
type: string |
|
id: string |
|
label: string |
|
}[] |
|
} |
|
|
|
type EdgeType = RawEdgeType & { |
|
sourceNode?: RawNodeType |
|
targetNode?: RawNodeType |
|
} |
|
|
|
const refineNodeProperties = (node: RawNodeType): NodeType => { |
|
const state = useGraphStore.getState() |
|
const relationships = [] |
|
|
|
if (state.sigmaGraph && state.rawGraph) { |
|
try { |
|
if (!state.sigmaGraph.hasNode(node.id)) { |
|
console.warn('Node not found in sigmaGraph:', node.id) |
|
return { |
|
...node, |
|
relationships: [] |
|
} |
|
} |
|
|
|
const edges = state.sigmaGraph.edges(node.id) |
|
|
|
for (const edgeId of edges) { |
|
if (!state.sigmaGraph.hasEdge(edgeId)) continue; |
|
|
|
const edge = state.rawGraph.getEdge(edgeId, true) |
|
if (edge) { |
|
const isTarget = node.id === edge.source |
|
const neighbourId = isTarget ? edge.target : edge.source |
|
|
|
if (!state.sigmaGraph.hasNode(neighbourId)) continue; |
|
|
|
const neighbour = state.rawGraph.getNode(neighbourId) |
|
if (neighbour) { |
|
relationships.push({ |
|
type: 'Neighbour', |
|
id: neighbourId, |
|
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ') |
|
}) |
|
} |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error refining node properties:', error) |
|
} |
|
} |
|
|
|
return { |
|
...node, |
|
relationships |
|
} |
|
} |
|
|
|
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => { |
|
const state = useGraphStore.getState() |
|
let sourceNode: RawNodeType | undefined = undefined |
|
let targetNode: RawNodeType | undefined = undefined |
|
|
|
if (state.sigmaGraph && state.rawGraph) { |
|
try { |
|
if (!state.sigmaGraph.hasEdge(edge.dynamicId)) { |
|
console.warn('Edge not found in sigmaGraph:', edge.id, 'dynamicId:', edge.dynamicId) |
|
return { |
|
...edge, |
|
sourceNode: undefined, |
|
targetNode: undefined |
|
} |
|
} |
|
|
|
if (state.sigmaGraph.hasNode(edge.source)) { |
|
sourceNode = state.rawGraph.getNode(edge.source) |
|
} |
|
|
|
if (state.sigmaGraph.hasNode(edge.target)) { |
|
targetNode = state.rawGraph.getNode(edge.target) |
|
} |
|
} catch (error) { |
|
console.error('Error refining edge properties:', error) |
|
} |
|
} |
|
|
|
return { |
|
...edge, |
|
sourceNode, |
|
targetNode |
|
} |
|
} |
|
|
|
const PropertyRow = ({ |
|
name, |
|
value, |
|
onClick, |
|
tooltip, |
|
nodeId, |
|
edgeId, |
|
dynamicId, |
|
entityId, |
|
entityType, |
|
sourceId, |
|
targetId, |
|
isEditable = false |
|
}: { |
|
name: string |
|
value: any |
|
onClick?: () => void |
|
tooltip?: string |
|
nodeId?: string |
|
entityId?: string |
|
edgeId?: string |
|
dynamicId?: string |
|
entityType?: 'node' | 'edge' |
|
sourceId?: string |
|
targetId?: string |
|
isEditable?: boolean |
|
}) => { |
|
const { t } = useTranslation() |
|
|
|
const getPropertyNameTranslation = (name: string) => { |
|
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}` |
|
const translation = t(translationKey) |
|
return translation === translationKey ? name : translation |
|
} |
|
|
|
|
|
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'keywords')) { |
|
return ( |
|
<EditablePropertyRow |
|
name={name} |
|
value={value} |
|
onClick={onClick} |
|
nodeId={nodeId} |
|
entityId={entityId} |
|
edgeId={edgeId} |
|
dynamicId={dynamicId} |
|
entityType={entityType} |
|
sourceId={sourceId} |
|
targetId={targetId} |
|
isEditable={true} |
|
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))} |
|
/> |
|
) |
|
} |
|
|
|
|
|
return ( |
|
<div className="flex items-center gap-2"> |
|
<span className="text-primary/60 tracking-wide whitespace-nowrap">{getPropertyNameTranslation(name)}</span>: |
|
<Text |
|
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis" |
|
tooltipClassName="max-w-80 -translate-x-13" |
|
text={value} |
|
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))} |
|
side="left" |
|
onClick={onClick} |
|
/> |
|
</div> |
|
) |
|
} |
|
|
|
const NodePropertiesView = ({ node }: { node: NodeType }) => { |
|
const { t } = useTranslation() |
|
|
|
const handleExpandNode = () => { |
|
useGraphStore.getState().triggerNodeExpand(node.id) |
|
} |
|
|
|
const handlePruneNode = () => { |
|
useGraphStore.getState().triggerNodePrune(node.id) |
|
} |
|
|
|
return ( |
|
<div className="flex flex-col gap-2"> |
|
<div className="flex justify-between items-center"> |
|
<h3 className="text-md pl-1 font-bold tracking-wide text-blue-700">{t('graphPanel.propertiesView.node.title')}</h3> |
|
<div className="flex gap-3"> |
|
<Button |
|
size="icon" |
|
variant="ghost" |
|
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700" |
|
onClick={handleExpandNode} |
|
tooltip={t('graphPanel.propertiesView.node.expandNode')} |
|
> |
|
<GitBranchPlus className="h-4 w-4 text-gray-700 dark:text-gray-300" /> |
|
</Button> |
|
<Button |
|
size="icon" |
|
variant="ghost" |
|
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700" |
|
onClick={handlePruneNode} |
|
tooltip={t('graphPanel.propertiesView.node.pruneNode')} |
|
> |
|
<Scissors className="h-4 w-4 text-gray-900 dark:text-gray-300" /> |
|
</Button> |
|
</div> |
|
</div> |
|
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> |
|
<PropertyRow name={t('graphPanel.propertiesView.node.id')} value={String(node.id)} /> |
|
<PropertyRow |
|
name={t('graphPanel.propertiesView.node.labels')} |
|
value={node.labels.join(', ')} |
|
onClick={() => { |
|
useGraphStore.getState().setSelectedNode(node.id, true) |
|
}} |
|
/> |
|
<PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} /> |
|
</div> |
|
<h3 className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.node.properties')}</h3> |
|
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> |
|
{Object.keys(node.properties) |
|
.sort() |
|
.map((name) => { |
|
if (name === 'created_at') return null; // Hide created_at property |
|
return ( |
|
<PropertyRow |
|
key={name} |
|
name={name} |
|
value={node.properties[name]} |
|
nodeId={String(node.id)} |
|
entityId={node.properties['entity_id']} |
|
entityType="node" |
|
isEditable={name === 'description' || name === 'entity_id'} |
|
/> |
|
) |
|
})} |
|
</div> |
|
{node.relationships.length > 0 && ( |
|
<> |
|
<h3 className="text-md pl-1 font-bold tracking-wide text-emerald-700"> |
|
{t('graphPanel.propertiesView.node.relationships')} |
|
</h3> |
|
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> |
|
{node.relationships.map(({ type, id, label }) => { |
|
return ( |
|
<PropertyRow |
|
key={id} |
|
name={type} |
|
value={label} |
|
onClick={() => { |
|
useGraphStore.getState().setSelectedNode(id, true) |
|
}} |
|
/> |
|
) |
|
})} |
|
</div> |
|
</> |
|
)} |
|
</div> |
|
) |
|
} |
|
|
|
const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => { |
|
const { t } = useTranslation() |
|
return ( |
|
<div className="flex flex-col gap-2"> |
|
<h3 className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</h3> |
|
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> |
|
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} /> |
|
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />} |
|
<PropertyRow |
|
name={t('graphPanel.propertiesView.edge.source')} |
|
value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source} |
|
onClick={() => { |
|
useGraphStore.getState().setSelectedNode(edge.source, true) |
|
}} |
|
/> |
|
<PropertyRow |
|
name={t('graphPanel.propertiesView.edge.target')} |
|
value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target} |
|
onClick={() => { |
|
useGraphStore.getState().setSelectedNode(edge.target, true) |
|
}} |
|
/> |
|
</div> |
|
<h3 className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.edge.properties')}</h3> |
|
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> |
|
{Object.keys(edge.properties) |
|
.sort() |
|
.map((name) => { |
|
if (name === 'created_at') return null; // Hide created_at property |
|
return ( |
|
<PropertyRow |
|
key={name} |
|
name={name} |
|
value={edge.properties[name]} |
|
edgeId={String(edge.id)} |
|
dynamicId={String(edge.dynamicId)} |
|
entityType="edge" |
|
sourceId={edge.sourceNode?.properties['entity_id'] || edge.source} |
|
targetId={edge.targetNode?.properties['entity_id'] || edge.target} |
|
isEditable={name === 'description' || name === 'keywords'} |
|
/> |
|
) |
|
})} |
|
</div> |
|
</div> |
|
) |
|
} |
|
|
|
export default PropertiesView |
|
|