From c970c041d16b868e562a4a9d0867333bc7dabf86 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 20:52:05 +0200 Subject: [PATCH] fix(ui): keep the whole graph on screen while the first scope picture is empty Selecting a symbol outside the loaded layout first arranges an empty preview. The scene took that empty arrangement as its placeholder and drew a blank canvas for about 250 ms before the scope appeared. An arrangement without nodes is no picture: the whole graph stays visible until the scope is laid out. Signed-off-by: Bernhard Jackiewicz --- graph-ui/src/galaxy/GalaxyPanel.tsx | 4 ++-- graph-ui/src/galaxy/graph-scope.test.ts | 15 +++++++++++++-- graph-ui/src/galaxy/graph-scope.ts | 11 +++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 6f3d48a31..098cb970f 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -143,7 +143,7 @@ import { galaxyPathText, galaxyToolbarText } from './galaxy-strings'; import { useOrganicLayout } from './use-organic-layout'; import RenderProgress from './RenderProgress'; import { useGraphScope } from './use-graph-scope'; -import { limitGraphRender, scopedHierarchy } from './graph-scope'; +import { limitGraphRender, scenePictureFor, scopedHierarchy } from './graph-scope'; import './graph-exploration.css'; import { layoutNodeForSelection } from './selected-node'; import { galaxyScopeEvidence, useSelectionEvidence, type SelectionEvidenceListener } from './selection-evidence'; @@ -790,7 +790,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { * der ganze Graph, der gerade noch zu sehen war. `data` bleibt dabei das * AKTUELLE Bild: Einpassung, Legende und Zaehler rechnen nie mit dem alten. */ - const sceneData = scope.scope ? (organic ?? organicTask.stale)?.data ?? layout : layout; + const sceneData = scope.scope ? scenePictureFor(organic?.data, organicTask.stale?.data, layout) : layout; const sceneScoped = Boolean(scope.scope) && sceneData !== layout; const scopedRoot = scope.result?.roots.size === 1 ? scope.result.data.nodes.find(node => scope.result!.roots.has(node.id)) : undefined; diff --git a/graph-ui/src/galaxy/graph-scope.test.ts b/graph-ui/src/galaxy/graph-scope.test.ts index 6fe3448a0..8c4ca316a 100644 --- a/graph-ui/src/galaxy/graph-scope.test.ts +++ b/graph-ui/src/galaxy/graph-scope.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { arrangeScopedGraph, graphEdgeTypesKey, limitGraphRender, loadGraphScope, readGraphPages, scopedHierarchy, type GraphQueryClient } from './graph-scope'; +import { arrangeScopedGraph, graphEdgeTypesKey, limitGraphRender, loadGraphScope, readGraphPages, scenePictureFor, scopedHierarchy, type GraphQueryClient } from './graph-scope'; import type { QueryGraphResult } from '../provider/rpc-schemas'; -import type { GraphNode } from './types'; +import type { GraphData, GraphNode } from './types'; const node = (id: number, file = 'src/a.ts', start = 1, end = 10): GraphNode => ({ id, name: `n${id}`, qualified_name: `p.n${id}`, file_path: file, label: 'Function', start_line: start, end_line: end, x: id, y: 0, z: 0, color: '#abcabc', size: 3 }); @@ -228,3 +228,14 @@ it('retains simple columns for small hierarchy levels', () => { const result = scopedHierarchy({ data: { nodes, edges: [], total_nodes: 3 }, roots: new Set([1]), depth: 1, exhausted: false }, 'small'); expect(result.data.nodes.map(node => [node.x, node.y])).toEqual([[0, 0], [160, 16], [160, -16]]); }); + +it('keeps the whole graph on screen while the first scope picture is still empty', () => { + const graph = (count: number): GraphData => ({ nodes: Array.from({ length: count }, (_, id) => ({ id, name: `n${id}`, label: 'Function', x: 0, y: 0, z: 0, size: 1, color: '#999999' })), edges: [], total_nodes: count }); + const layout = graph(5), stale = graph(2), current = graph(3), empty = graph(0); + // An empty preview of a symbol outside the loaded layout is no picture. + expect(scenePictureFor(empty, undefined, layout)).toBe(layout); + expect(scenePictureFor(undefined, empty, layout)).toBe(layout); + expect(scenePictureFor(empty, stale, layout)).toBe(stale); + expect(scenePictureFor(current, stale, layout)).toBe(current); + expect(scenePictureFor(undefined, stale, layout)).toBe(stale); +}); diff --git a/graph-ui/src/galaxy/graph-scope.ts b/graph-ui/src/galaxy/graph-scope.ts index 902c3c648..41ba49c85 100644 --- a/graph-ui/src/galaxy/graph-scope.ts +++ b/graph-ui/src/galaxy/graph-scope.ts @@ -198,6 +198,17 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth: return { data: arrangeScopedGraph([...nodes.values()], [...edges.values()], roots, levels), roots, depth: reachedDepth, exhausted: edgeTypes?.length === 0 || (depth > 0 && frontier.length === 0), frontier, levels, traversalKey }; } +/** Was die Galaxie zeigt, waehrend ein Scope angeordnet wird: das aktuelle + * Bild, sonst die letzte Anordnung, sonst den ganzen Graphen. Eine Anordnung + * ohne Knoten ist kein Bild. Liegt das gewaehlte Symbol ausserhalb des + * geladenen Layouts, ist die erste Vorschau leer, und als Platzhalter stand + * dann eine leere Flaeche statt des Graphen, der gerade noch zu sehen war. */ +export function scenePictureFor(current: GraphData | undefined, stale: GraphData | undefined, layout: GraphData | undefined): GraphData | undefined { + if (current && current.nodes.length > 0) return current; + if (stale && stale.nodes.length > 0) return stale; + return layout; +} + export function limitGraphRender(data: GraphData, nodeLimit: number, edgeLimit: number, required: ReadonlySet = new Set()): GraphData { const nodes = [...data.nodes.filter(node => required.has(node.id)), ...data.nodes.filter(node => !required.has(node.id))] .slice(0, Math.max(0, Math.floor(nodeLimit)));