Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/components/universe/graph-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,13 @@ function apiToGraph(
for (const [key, arr] of bundles) {
if (arr.length < CLUSTER_THRESHOLD) continue
const [source, edge_type, target_type] = key.split("::")
// Skip clusters whose source isn't in the loaded payload — buildGraph drops
// edges with unknown endpoints, so the cluster's parent edge would vanish
// and the proxy would end up as an orphan synthetic root with no visible
// parent. Let the targets fall back to __group_<type> grouping instead.
if (!nodeTypeById.has(source)) continue
const clusterId = `__cluster_${source}_${edge_type}_${target_type}`
extraNodes.push({ id: clusterId, label: `${target_type} × ${arr.length}` })
extraNodes.push({ id: clusterId, label: `${target_type} × ${arr.length} · ${edge_type}` })
extraEdges.push({ source, target: clusterId, label: edge_type })
for (const e of arr) {
extraEdges.push({ source: clusterId, target: e.target, label: edge_type })
Expand Down
7 changes: 5 additions & 2 deletions src/components/universe/graph-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export function GraphPane() {
const edges = useGraphStore((s) => s.edges)
const selectedNode = useGraphStore((s) => s.selectedNode)
const setSelectedNode = useGraphStore((s) => s.setSelectedNode)
const setSidebarSelectedNode = useGraphStore((s) => s.setSidebarSelectedNode)
const clearSelection = useGraphStore((s) => s.clearSelection)
const schemas = useSchemaStore((s) => s.schemas)

Expand All @@ -33,9 +32,13 @@ export function GraphPane() {
const workflowsOpen = useAppStore((s) => s.workflowsOpen)
const toggleWorkflows = useAppStore((s) => s.toggleWorkflows)

// Canvas clicks only update `selectedNode` (drives the preview panel).
// `sidebarSelectedNode` stays for sidebar-list selections, which gate the
// 1-hop neighbor fetch (use-sidebar-neighbor-fetch). Re-firing the fetch
// on every canvas click re-parented absorbed members across rebuilds and
// sent the camera to stale positions.
function onSelect(node: GraphNode) {
setSelectedNode(node)
setSidebarSelectedNode(node)
}

function openPanel(toggle: () => void) {
Expand Down
96 changes: 48 additions & 48 deletions src/graph-viz-kit/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,6 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima


const highlightedEdges = useMemo(() => {
if (hovered === null && selectedId === null) return [];
// Source from graph.edges (not targetEdges) so cross-edges touching the
// hovered/selected node surface here even when the subgraph-mode filter
// strips them out of the base render. Skip extraEdges (cluster-absorbed
Expand All @@ -766,44 +765,47 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
const visibleSet = viewState.mode === "subgraph"
? new Set(viewState.visibleNodeIds)
: null;
const real = graph.edges.filter((e) => {
const touches =
e.src === hovered || e.dst === hovered ||
e.src === selectedId || e.dst === selectedId;
if (!touches) return false;
const srcType = graph.nodes[e.src]?.nodeType;
if (srcType === "_group" || srcType === "_cluster") {
// Spokes are dropped by default — for large clusters the ring chord
// polygon below handles them. For a small cluster (<RING_MIN_MEMBERS)
// the ring would degenerate to a triangle/line, so we instead show
// the spokes themselves — but only when the proxy itself is the focus.
const isFocusedProxy = e.src === hovered || e.src === selectedId;
if (!isFocusedProxy) return false;
const visibleMembers = (graph.outAdj?.[e.src] ?? []).filter(
(m) => !visibleSet || visibleSet.has(m),
);
if (visibleMembers.length >= RING_MIN_MEMBERS) return false;
}
if (visibleSet && (!visibleSet.has(e.src) || !visibleSet.has(e.dst))) return false;
return true;
});

// Ring chord polygon (Feature 5): when the focused node is a proxy with
// ≥ RING_MIN_MEMBERS, connect members in angular order around the proxy
// so the cluster reads as a perimeter rather than N independent spokes.
// outAdj (children only) — using graph.adj would pull the proxy's source
// (e.g. the parent Episode) into the perimeter.
const hasFocus = hovered !== null || selectedId !== null;
const real = !hasFocus
? []
: graph.edges.filter((e) => {
const touches =
e.src === hovered || e.dst === hovered ||
e.src === selectedId || e.dst === selectedId;
if (!touches) return false;
const srcType = graph.nodes[e.src]?.nodeType;
if (srcType === "_group" || srcType === "_cluster") {
// Spokes are dropped by default — for large clusters the ring chord
// polygon below handles them. For a small cluster (<RING_MIN_MEMBERS)
// the ring would degenerate to a triangle/line, so we instead show
// the spokes themselves — but only when the proxy itself is the focus.
const isFocusedProxy = e.src === hovered || e.src === selectedId;
if (!isFocusedProxy) return false;
const visibleMembers = (graph.outAdj?.[e.src] ?? []).filter(
(m) => !visibleSet || visibleSet.has(m),
);
if (visibleMembers.length >= RING_MIN_MEMBERS) return false;
}
if (visibleSet && (!visibleSet.has(e.src) || !visibleSet.has(e.dst))) return false;
return true;
});

// Ring chord polygon (Feature 5): connect members in angular order around
// every cluster/group proxy with ≥ RING_MIN_MEMBERS members so the cluster
// reads as a perimeter. Always rendered for `_group` proxies (members are
// real laid-out roots/orphans). For `_cluster` proxies, members stay
// collapsed/invisible at a tiny offset until the proxy is expanded, so we
// only draw the ring while expanded — otherwise the perimeter degenerates
// into a tight stack of dots at the proxy center.
const ring: GraphEdge[] = [];
const seen = new Set<number>();
const addRingFor = (proxyId: number | null) => {
if (proxyId === null || seen.has(proxyId)) return;
seen.add(proxyId);
for (let proxyId = 0; proxyId < graph.nodes.length; proxyId++) {
const nodeType = graph.nodes[proxyId]?.nodeType;
if (nodeType !== "_group" && nodeType !== "_cluster") return;
if (nodeType !== "_group" && nodeType !== "_cluster") continue;
if (nodeType === "_cluster" && proxyId !== expandedClusterId) continue;
const members = (graph.outAdj?.[proxyId] ?? []).filter(
(m) => !visibleSet || visibleSet.has(m),
);
if (members.length < RING_MIN_MEMBERS) return;
if (members.length < RING_MIN_MEMBERS) continue;
const p = graph.nodes[proxyId].position;
const sorted = members
.slice()
Expand All @@ -819,12 +821,10 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
type: RING_EDGE_TYPE,
});
}
};
addRingFor(hovered);
addRingFor(selectedId);
}

return ring.length > 0 ? [...real, ...ring] : real;
}, [hovered, selectedId, graph.edges, graph.nodes, graph.outAdj, viewState]);
}, [hovered, selectedId, graph.edges, graph.nodes, graph.outAdj, viewState, expandedClusterId]);

// For each neighbor of the focused node, the set of edge types connecting
// it to the focused node. Lets the label render show "via MENTIONS" etc.
Expand Down Expand Up @@ -1840,11 +1840,11 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
: isHoverNeighbor ? "rgba(200,200,200,0.85)"
: isRecentNode ? `rgba(100,255,180,${(0.5 + 0.45 * recentOpacity).toFixed(2)})`
: "rgba(190,200,210,0.75)";
const labelSize = isHovered || isSelected ? 14
: isTopHit ? (topRank === 0 ? 16 : 14)
: isRecentNode ? 13
: isHoverNeighbor ? 12
: 11;
const labelSize = isHovered || isSelected ? 15
: isTopHit ? (topRank === 0 ? 17 : 15)
: isRecentNode ? 14
: isHoverNeighbor ? 13
: 12;
const labelWeight = isHovered || isSelected || isExpandedProxy || isTopHit ? 700 : 500;

const iconColor = node.icon
Expand Down Expand Up @@ -1895,7 +1895,7 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
>
<div style={{
color: isExpandedProxy ? "rgba(100,220,255,0.95)" : labelColor,
fontSize: isExpandedProxy ? 13 : labelSize,
fontSize: isExpandedProxy ? 14 : labelSize,
fontFamily: "'Barlow', sans-serif",
fontWeight: labelWeight,
letterSpacing: "0.3px",
Expand Down Expand Up @@ -1939,7 +1939,7 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.07)",
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: 7,
fontSize: 9,
color: "rgba(190,205,215,0.78)",
letterSpacing: "0.02em",
lineHeight: 1.4,
Expand All @@ -1949,14 +1949,14 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 11, height: 11,
width: 13, height: 13,
borderRadius: 2,
background: `${tint}1f`,
border: `1px solid ${tint}55`,
color: tint,
lineHeight: 1,
}}>
<Icon size={7} strokeWidth={2} />
<Icon size={9} strokeWidth={2} />
</span>
{node.nodeType!.toLowerCase()}
</div>
Expand All @@ -1967,7 +1967,7 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
return (
<div key={t} style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: 7,
fontSize: 9,
color: rgbToCss(ec, 0.95),
background: rgbToCss(ec, 0.12),
border: `1px solid ${rgbToCss(ec, 0.55)}`,
Expand Down
Loading