diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7ea76a6..774c7f8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,6 +30,8 @@ struct GraphNode { id: String, name: String, label: String, + #[serde(skip_serializing_if = "HashMap::is_empty", default)] + properties: HashMap, #[serde(rename = "tableId", skip_serializing_if = "Option::is_none")] table_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -286,6 +288,10 @@ fn value_to_string(val: &Value) -> String { Value::Int32(n) => n.to_string(), Value::Int16(n) => n.to_string(), Value::Int8(n) => n.to_string(), + Value::UInt64(n) => n.to_string(), + Value::UInt32(n) => n.to_string(), + Value::UInt16(n) => n.to_string(), + Value::UInt8(n) => n.to_string(), Value::Double(n) => n.to_string(), Value::Float(n) => n.to_string(), Value::Bool(b) => b.to_string(), @@ -293,6 +299,80 @@ fn value_to_string(val: &Value) -> String { } } +fn non_empty_value_to_string(val: &Value) -> Option { + let value = value_to_string(val); + if value.trim().is_empty() { + None + } else { + Some(value) + } +} + +fn node_display_name(props: &[(String, Value)]) -> String { + const PREFERRED_KEYS: &[&str] = &[ + "name", + "title", + "display_name", + "displayname", + "label", + "summary", + "subject", + "key", + "slug", + "path", + "filename", + "file", + "url", + "uri", + "email", + "username", + "user_name", + ]; + + for preferred_key in PREFERRED_KEYS { + if let Some((_, value)) = props + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(preferred_key)) + { + if let Some(name) = non_empty_value_to_string(value) { + return name; + } + } + } + + if let Some((_, value)) = props + .iter() + .find(|(key, value)| !key.eq_ignore_ascii_case("id") && matches!(value, Value::String(_))) + { + if let Some(name) = non_empty_value_to_string(value) { + return name; + } + } + + if let Some((_, value)) = props + .iter() + .find(|(key, value)| key.eq_ignore_ascii_case("id") && matches!(value, Value::String(_))) + { + if let Some(name) = non_empty_value_to_string(value) { + return name; + } + } + + props + .iter() + .find_map(|(_, value)| non_empty_value_to_string(value)) + .unwrap_or_else(|| "Node".to_string()) +} + +fn node_display_properties(props: &[(String, Value)]) -> HashMap { + props + .iter() + .filter_map(|(key, value)| { + non_empty_value_to_string(value).map(|value| (key.clone(), value)) + }) + .collect() +} + fn value_to_score(val: &Value) -> f64 { match val { Value::Double(n) => *n, @@ -315,18 +395,14 @@ fn graph_node_from_value(val: &Value) -> Option { }; let props = node_val.get_properties(); - let name = props - .iter() - .find(|(key, _)| key == "name") - .or_else(|| props.iter().find(|(key, _)| key == "id")) - .or_else(|| props.iter().find(|(key, _)| key == "title")) - .map(|(_, prop_val)| value_to_string(prop_val)) - .unwrap_or_else(|| "Node".to_string()); + let name = node_display_name(props); + let properties = node_display_properties(props); Some(GraphNode { id: id_to_string(node_val.get_node_id()), name, label: node_val.get_label_name().clone(), + properties, table_id: Some(node_val.get_node_id().table_id), rowid: Some(node_val.get_node_id().offset), community: None, @@ -432,6 +508,7 @@ fn make_expander_node(parent_id: &str, hidden_count: usize, offset: usize) -> Gr id: format!("{EXPANDER_PREFIX}node:{parent_id}:{offset}"), name: format!("+{hidden_count}"), label: "More".to_string(), + properties: HashMap::new(), table_id: None, rowid: None, community: None, @@ -986,6 +1063,7 @@ fn compute_cluster_levels( id: index.to_string(), name: format!("Cluster {index}"), label: "Cluster".to_string(), + properties: HashMap::new(), table_id: None, rowid: None, community: Some(index as u64), @@ -1204,19 +1282,15 @@ fn collect_edge_graph(conn: &Connection, limit: usize) -> Result tableId?: number rowid?: number community?: number @@ -144,6 +145,7 @@ interface SigmaGraphViewProps { labelNodeIds: Set newlyExpandedNodeIds: Set darkMode: boolean + getNodeDisplayName: (node: GraphNode) => string getNodeColor: (node: GraphNode) => string getNodeSize: (node: GraphNode) => number getEdgeColor: (label: string) => string @@ -193,9 +195,23 @@ function buildLlmClusterConfig(config: LlmClusterNamingConfig): LlmClusterNaming } } -function getNodeClusterLabel(node: GraphNode) { +const AUTO_DISPLAY_COLUMN = '__auto__' + +function getNodeDisplayName(node: GraphNode, displayColumns: Record) { + if (isExpanderNode(node) || isClusterNode(node)) return node.name || node.id + + const selectedColumn = displayColumns[node.label] + if (selectedColumn && selectedColumn !== AUTO_DISPLAY_COLUMN) { + const selectedValue = node.properties?.[selectedColumn] + if (selectedValue?.trim()) return selectedValue + } + + return node.name || node.id +} + +function getNodeClusterLabel(node: GraphNode, displayColumns: Record) { const label = node.label.trim() - const name = node.name.trim() + const name = getNodeDisplayName(node, displayColumns).trim() return !name || name === label ? label : `${label}: ${name}` } @@ -699,7 +715,7 @@ function createInitialLayout(graphData: NormalizedGraphData) { return { degrees, positions } } -function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMode, getNodeColor, getNodeSize, getEdgeColor, onNodeClick }: SigmaGraphViewProps) { +function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMode, getNodeDisplayName, getNodeColor, getNodeSize, getEdgeColor, onNodeClick }: SigmaGraphViewProps) { const containerRef = useRef(null) const rendererRef = useRef(null) const hoveredEdgeRef = useRef(null) @@ -708,6 +724,7 @@ function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMod const { positions } = createInitialLayout(graphData) const nodes = graphData.nodes.map(node => { const position = positions[node.id] || { x: 0, y: 0 } + const displayName = getNodeDisplayName(node) return { key: node.id, attributes: { @@ -715,8 +732,8 @@ function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMod y: position.y, size: getNodeSize(node), color: isExpanderNode(node) ? '#f59e0b' : getNodeColor(node), - label: isExpanderNode(node) || labelNodeIds.has(node.id) ? node.name || node.id : '', - hoverLabel: node.name || node.id, + label: isExpanderNode(node) || labelNodeIds.has(node.id) ? displayName : '', + hoverLabel: displayName, isNewlyExpanded: newlyExpandedNodeIds.has(node.id), nodeType: node.label, }, @@ -752,7 +769,7 @@ function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMod edgeAttributes, edgeKeys, }) - }, [graphData, labelNodeIds, newlyExpandedNodeIds, getNodeColor, getNodeSize, getEdgeColor]) + }, [graphData, labelNodeIds, newlyExpandedNodeIds, getNodeDisplayName, getNodeColor, getNodeSize, getEdgeColor]) useEffect(() => { const container = containerRef.current @@ -857,6 +874,7 @@ function App() { const [clusterViewEnabled, setClusterViewEnabled] = useState(true) const [nodeSearch, setNodeSearch] = useState('') const [searchResults, setSearchResults] = useState([]) + const [displayColumnsByLabel, setDisplayColumnsByLabel] = useState>({}) const [searching, setSearching] = useState(false) const [searchError, setSearchError] = useState(null) const [focusedNodeId, setFocusedNodeId] = useState(null) @@ -1159,6 +1177,27 @@ function App() { }, [graphData, selectedId, currentLlmClusterConfig, resetClusterNameRequests]) const normalizedGraphData = useMemo(() => normalizeGraphData(graphData), [graphData]) + const displayColumnOptions = useMemo(() => { + const columnsByLabel = new Map>() + graphData.nodes.forEach(node => { + if (isExpanderNode(node) || isClusterNode(node)) return + const propertyNames = Object.keys(node.properties || {}) + if (propertyNames.length === 0) return + const columns = columnsByLabel.get(node.label) || new Set() + propertyNames.forEach(name => columns.add(name)) + columnsByLabel.set(node.label, columns) + }) + + return [...columnsByLabel.entries()] + .map(([label, columns]) => ({ + label, + columns: [...columns].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.label.localeCompare(b.label)) + }, [graphData.nodes]) + const getDisplayName = useCallback((node: GraphNode) => ( + getNodeDisplayName(node, displayColumnsByLabel) + ), [displayColumnsByLabel]) const clusterLevels = useMemo(() => buildCommunityClusterLevels(normalizedGraphData), [normalizedGraphData]) const coarsestClusterLevel = useMemo(() => getCoarsestClusterLevel(clusterLevels), [clusterLevels]) const currentClusterLevel = useMemo(() => { @@ -1184,6 +1223,10 @@ function App() { : normalizedGraphData ), [clusterViewEnabled, clusterLevels, normalizedGraphData, visibleClusterPath]) + useEffect(() => { + resetClusterNameRequests() + }, [displayColumnsByLabel, resetClusterNameRequests]) + useEffect(() => { const llmConfig = currentLlmClusterConfig() if (!llmConfig || !clusterViewEnabled || !currentClusterLevel) return @@ -1207,7 +1250,7 @@ function App() { && currentClusterLevel.membership[index] === clusterId && nodeMatchesClusterPath(clusterLevels, index, visibleClusterPath) )) - .map(getNodeClusterLabel) + .map(node => getNodeClusterLabel(node, displayColumnsByLabel)) const sampledLabels = sampleLabels(labels, llmConfig.sampleSize) if (sampledLabels.length === 0) return @@ -1273,6 +1316,7 @@ function App() { clusterViewEnabled, currentClusterLevel, currentLlmClusterConfig, + displayColumnsByLabel, normalizedGraphData, visibleClusterPath, visibleGraphData.nodes, @@ -1382,7 +1426,8 @@ function App() { ctx.lineWidth = highlighted ? 3 : 1 ctx.stroke() - if ((isExpanderNode(node) || topLabelNodeIds.has(node.id)) && node.name) { + const displayName = getDisplayName(node) + if ((isExpanderNode(node) || topLabelNodeIds.has(node.id)) && displayName) { const fontSize = 3 ctx.font = `${fontSize}px Sans-Serif` ctx.textAlign = 'center' @@ -1390,7 +1435,7 @@ function App() { ctx.fillStyle = highlighted ? '#f59e0b' : '#fff' const maxWidth = size * 1.6 - let label = node.name + let label = displayName const measured = ctx.measureText(label) if (measured.width > maxWidth) { while (label.length > 1 && ctx.measureText(label + '\u2026').width > maxWidth) { @@ -1400,7 +1445,7 @@ function App() { } ctx.fillText(label, node.x, node.y) } - }, [getNodeSize, getNodeColor, darkMode, topLabelNodeIds, lastExpandedNodeIds]) + }, [getNodeSize, getNodeColor, getDisplayName, darkMode, topLabelNodeIds, lastExpandedNodeIds]) return (
@@ -1439,6 +1484,40 @@ function App() { ))} )} + {displayColumnOptions.length > 0 && ( +
+
Node Labels
+
+ {displayColumnOptions.map(({ label, columns }) => { + const selectedColumn = displayColumnsByLabel[label] + const selectedValue = selectedColumn && columns.includes(selectedColumn) + ? selectedColumn + : AUTO_DISPLAY_COLUMN + + return ( + + ) + })} +
+
+ )}
Find Node
@@ -1464,9 +1543,9 @@ function App() { key={result.id} className="search-result" onClick={() => exploreSearchResult(result)} - title={`${result.label}: ${result.name}`} + title={`${result.label}: ${getDisplayName(result)}`} > - {result.name} + {getDisplayName(result)} {result.label} ยท {result.id} ))} @@ -1605,6 +1684,7 @@ function App() { labelNodeIds={topLabelNodeIds} newlyExpandedNodeIds={lastExpandedNodeIds} darkMode={darkMode} + getNodeDisplayName={getDisplayName} getNodeColor={getNodeColor} getNodeSize={getNodeSize} getEdgeColor={getEdgeColor} @@ -1622,7 +1702,7 @@ function App() { onNodeClick={(node) => handleVisibleNodeClick(String(node.id))} nodeVal={(node) => { const s = getNodeSize(node); return s * s; }} nodeRelSize={1} - nodeLabel={(node) => `${node.label}: ${node.name}`} + nodeLabel={(node) => `${node.label}: ${getDisplayName(node)}`} linkLabel={(link) => link.label} linkColor={(link) => getEdgeColor(link.label)} linkWidth={2.5}