From 011f414ce1e927ea2b5d2d6b4d6690436aaa0053 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 14:45:13 +0200 Subject: [PATCH] fix(ui): show the node hover card above the Galaxy panels The stacking context that keeps the scene labels below the Galaxy panels (K29) took the hover card of a node with it: next to Selection details the card was cut off by the panel. A hover card is only there while the pointer rests on a node, and then it is what the reader wants to read. The scene now keeps a second layer beside the canvas wrapper, as large as the scene, and the node card and the coverage card draw into it. drei still places them with the scene camera, so they stand where they stood before. The layer lies above every Galaxy panel and below the toolbar menus and the tooltips, so an open menu stays readable. Signed-off-by: Bernhard Jackiewicz --- graph-ui/src/galaxy/GalaxyPanel.tsx | 5 +- graph-ui/src/galaxy/GraphScene.tsx | 17 +++- graph-ui/src/galaxy/NodeTooltipCard.tsx | 11 +-- graph-ui/src/galaxy/hover-layer.test.tsx | 99 ++++++++++++++++++++++++ graph-ui/src/galaxy/hover-layer.tsx | 47 +++++++++++ 5 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 graph-ui/src/galaxy/hover-layer.test.tsx create mode 100644 graph-ui/src/galaxy/hover-layer.tsx diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 63985d3c2..872429e67 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -135,6 +135,7 @@ import { GraphScene, computeCameraTarget, computeFitTarget, computeFrameTarget } import type { CameraTarget } from './GraphScene'; import type { LabelBox } from './NodeLabels'; import { NodeTooltipCard } from './NodeTooltipCard'; +import { HoverCardHtml } from './hover-layer'; import GalaxyNavigator from './GalaxyNavigator'; import { TraceEdgeFilter } from './TraceEdgeFilter'; import { PathPicker, PathSteps } from './ScopePathControls'; @@ -2919,9 +2920,9 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { props.onSelectShadowNode?.(node); setNote(`${node.file_path ?? node.name}: coverage shadow. Detailed indexing reasons are not included in this layout.`); }} - renderShadowTooltip={(node) => + renderShadowTooltip={(node) =>
{node.file_path ?? node.name}

Coverage shadow: not fully indexed.

- } +
} onBackgroundClick={handleBackgroundClick} renderTooltip={(node) => } overlay={overlay} diff --git a/graph-ui/src/galaxy/GraphScene.tsx b/graph-ui/src/galaxy/GraphScene.tsx index bb1d8d190..5a189aa72 100644 --- a/graph-ui/src/galaxy/GraphScene.tsx +++ b/graph-ui/src/galaxy/GraphScene.tsx @@ -112,6 +112,9 @@ * Wurzel und ihre direkten Nachbarn). Ohne die Prop wie vorher. * 22. Neu (Handtest 2026-10-04, G1): die Huelle des Canvas bildet ihren * eigenen Stapelkontext (`isolation: isolate`). Siehe `CANVAS_STYLE`. + * 23. Neu (Review zu K29): neben der Huelle steht die Ebene der Hover-Karten, + * damit eine Karte ueber den Flaechen der Galaxie steht und nicht wie die + * Namen darunter. Siehe hover-layer.tsx. */ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; @@ -136,6 +139,7 @@ import { FRAME_WINDOW_MS, recordFrameWindow, recordSceneFacts } from './frame-ra import { springStep } from '../agents/agent-motion'; import type { GraphData, GraphNode } from './types'; import { COVERAGE_SHADOW_COLOR, coverageShadowPositions, resolveCoverageShadowNode } from './coverage-shadow'; +import { HOVER_LAYER_STYLE, HoverLayerContext } from './hover-layer'; import type { CoverageShadow, CoverageShadowNode } from './coverage-shadow'; import { DEFAULT_DISPLAY_SETTINGS, @@ -1106,8 +1110,10 @@ export function GraphScene({ * Beweislaeufe von W3 bis W9 gemessen haben. */ const frameloop = !active ? 'never' : frameCap > 0 ? 'never' : 'always'; + /* Aenderung 23: die Hover-Karten zeichnen ausserhalb der Huelle in diese Ebene. */ + const [hoverLayer, setHoverLayer] = useState(null); - return ( + return (<> } {overlay} - {hovered && renderTooltip !== undefined && renderTooltip(hovered)} - {resolveCoverageShadowNode(renderedShadow, hoveredShadow) && hoveredShadow && renderShadowTooltip?.(hoveredShadow)} + + {hovered && renderTooltip !== undefined && renderTooltip(hovered)} + {resolveCoverageShadowNode(renderedShadow, hoveredShadow) && hoveredShadow && renderShadowTooltip?.(hoveredShadow)} + {flat && } - ); +
+ ); } /* diff --git a/graph-ui/src/galaxy/NodeTooltipCard.tsx b/graph-ui/src/galaxy/NodeTooltipCard.tsx index f4bb3a91a..26a6936b2 100644 --- a/graph-ui/src/galaxy/NodeTooltipCard.tsx +++ b/graph-ui/src/galaxy/NodeTooltipCard.tsx @@ -16,7 +16,7 @@ */ import type { JSX } from 'react'; -import { Html } from '@react-three/drei'; +import { HoverCardHtml } from './hover-layer'; import type { GraphNode } from './types'; /** Der Zeilenbereich, wenn das Layout einen fuehrt. */ @@ -60,11 +60,8 @@ export function NodeTooltipCard({ node }: { node: GraphNode }): JSX.Element { const rows = tooltipRows(node); const lines = lineRangeOf(node); return ( - + /* Review zu K29: in der Ebene der Hover-Karten, ueber den Flaechen der Galaxie (hover-layer.tsx). */ +
@@ -92,7 +89,7 @@ export function NodeTooltipCard({ node }: { node: GraphNode }): JSX.Element { )}

{tooltipAction(node)}

- + ); } diff --git a/graph-ui/src/galaxy/hover-layer.test.tsx b/graph-ui/src/galaxy/hover-layer.test.tsx new file mode 100644 index 000000000..fb2fd64af --- /dev/null +++ b/graph-ui/src/galaxy/hover-layer.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +/* + * Review zu K29: seit G1 bildet die Huelle des Canvas ihren eigenen + * Stapelkontext, damit die Namen der Szene unter den Bedienflaechen bleiben. + * Dieselbe Regel legte die Hover-Karte eines Knotens unter jede Flaeche: neben + * "Selection details" war sie abgeschnitten. Eine Hover-Karte ist fluechtig und + * muss lesbar sein. Hier steht, dass sie in einer eigenen Ebene ausserhalb der + * Huelle steht, ueber den Flaechen der Galaxie und unter ihren Menues und + * Tooltips. + */ +import { act, useState } from 'react'; +import type { ReactNode } from 'react'; +import { readFileSync } from 'node:fs'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const html = vi.hoisted(() => ({ props: [] as Record[] })); +vi.mock('@react-three/drei', async importOriginal => ({ + ...await importOriginal(), + Html: (props: Record & { children?: ReactNode }) => { html.props.push(props); return
{props.children}
; }, +})); + +const { GraphScene } = await import('./GraphScene'); +const { HoverLayerContext, HOVER_LAYER_Z_INDEX } = await import('./hover-layer'); +const { NodeTooltipCard } = await import('./NodeTooltipCard'); + +let host: HTMLDivElement, root: Root; +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + (globalThis as unknown as Record).ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} }; + host = document.createElement('div'); document.body.append(host); root = createRoot(host); + html.props = []; +}); +afterEach(() => { act(() => root.unmount()); host.remove(); }); + +const node = { id: 1, x: 0, y: 0, z: 0, name: 'n1', label: 'Function', size: 3, color: '#abcdef', file_path: 'a.py' }; + +/** The z-index of the last rule whose selector list names `selector` exactly, across the given sheets. */ +function zIndex(selector: string, ...files: string[]): number { + let found: number | undefined; + for (const file of files) { + const css = readFileSync(new URL(file, import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + for (const match of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) { + if (!match[1]!.split(',').map(item => item.trim()).includes(selector)) continue; + const value = /z-index:\s*(\d+)/.exec(match[2]!)?.[1]; + if (value !== undefined) found = Number(value); + } + } + expect(found, selector).toBeDefined(); + return found!; +} +const SHEETS = ['./graph-exploration.css', '../styles/terminal.css', '../why/selection-context.css']; + +describe('K29: the hover card stands above the Galaxy panels', () => { + it('GraphScene keeps a hover layer beside the isolated canvas wrapper, not inside it', () => { + act(() => root.render( {}} renderTooltip={() => null} />)); + const wrapper = host.querySelector('canvas')!.parentElement!.parentElement!; + const layer = host.querySelector('[data-testid="atlas-galaxy-hover-layer"]'); + expect(layer).not.toBeNull(); + expect(layer!.parentElement).toBe(host); + expect(wrapper.contains(layer)).toBe(false); + expect(layer!.style.position).toBe('absolute'); + expect(layer!.style.pointerEvents).toBe('none'); + expect(Number(layer!.style.zIndex)).toBe(HOVER_LAYER_Z_INDEX); + }); + + it('lies over every Galaxy panel and under the toolbar menus and tooltips', () => { + const panels = { + 'Selection details': zIndex('.galaxy-selection-evidence', ...SHEETS), + 'path panel': zIndex('.atlas-galaxy-path-panel', ...SHEETS), + 'hierarchy key': zIndex('.atlas-hierarchy-key', ...SHEETS), + 'fit view': zIndex('.atlas-galaxy-fit', ...SHEETS), + 'agents instrument': zIndex('.atlas-agents', ...SHEETS), + 'agents strip': zIndex('.atlas-galaxy-bottom', ...SHEETS), + }; + const above = { + 'Path to menu': zIndex('.atlas-graph-path-menu', ...SHEETS), + 'Edge types menu': zIndex('.atlas-trace-edge-menu', ...SHEETS), + 'more menu': zIndex('.atlas-graph-limits-menu', ...SHEETS), + 'Recent menu': zIndex('.atlas-graph-recent-menu', ...SHEETS), + 'search results': zIndex('.atlas-galaxy-search-results', ...SHEETS), + tooltip: zIndex('.atlas-hint', ...SHEETS), + }; + for (const [name, value] of Object.entries(panels)) expect(HOVER_LAYER_Z_INDEX, name).toBeGreaterThan(value); + for (const [name, value] of Object.entries(above)) expect(HOVER_LAYER_Z_INDEX, name).toBeLessThan(value); + }); + + it('the node card draws into that layer', () => { + function Probe() { + const [layer, setLayer] = useState(null); + return <>
; + } + act(() => root.render()); + const props = html.props.at(-1)!; + expect((props.portal as { current: HTMLElement }).current).toBe(host.firstElementChild); + expect(props.center).toBe(true); + }); +}); diff --git a/graph-ui/src/galaxy/hover-layer.tsx b/graph-ui/src/galaxy/hover-layer.tsx new file mode 100644 index 000000000..ab0012bc7 --- /dev/null +++ b/graph-ui/src/galaxy/hover-layer.tsx @@ -0,0 +1,47 @@ +/* + * Die Ebene der Hover-Karten (Review zu K29). + * + * Seit G1 bildet die Huelle des Canvas ihren eigenen Stapelkontext: jedes + * der Szene bleibt darin und damit unter den Bedienflaechen der + * Galaxie. Fuer Namen ist das richtig, fuer eine Hover-Karte nicht: sie + * erscheint nur, solange der Zeiger auf einem Knoten steht, und ist dann das, + * was der Leser lesen will. Neben "Selection details" war sie abgeschnitten. + * + * Darum steht neben der Huelle eine eigene Ebene, so gross wie die Szene, und + * die Karten haengen sich dort hinein (drei `portal`). drei rechnet ihre Lage + * weiter mit der Kamera der Szene; die Ebene liegt deckungsgleich ueber dem + * Canvas, also steht die Karte am selben Punkt wie vorher. Ihr z-index liegt + * ueber jeder Flaeche der Galaxie (Selection details 4, Hierarchie-Notiz 20, + * Pfadliste 25) und unter den Menues der Werkzeugleiste (30, die Suche 35) und + * den Tooltips (40): ein offenes Menue bleibt lesbar, auch wenn eine Karte + * darunter steht. + */ +import { createContext, useContext, useMemo } from 'react'; +import type { CSSProperties, JSX, ReactNode, RefObject } from 'react'; +import { Html } from '@react-three/drei'; + +export const HOVER_LAYER_Z_INDEX = 28; + +export const HOVER_LAYER_STYLE: CSSProperties = { + position: 'absolute', inset: 0, zIndex: HOVER_LAYER_Z_INDEX, pointerEvents: 'none', overflow: 'hidden', +}; + +/* + * Die Ebene, in die eine Hover-Karte zeichnet; ohne sie bleibt die Karte, wie + * drei es tut, in der Huelle. Das Element selbst und kein Ref: drei liest sein + * Ziel beim Rendern, und erst ein neuer Wert sagt der Karte, dass die Ebene + * jetzt steht. + */ +export const HoverLayerContext = createContext(null); + +/** Eine Karte an einem Punkt der Szene, in der Ebene der Hover-Karten. */ +export function HoverCardHtml({ position, children }: { position: [number, number, number]; children: ReactNode }): JSX.Element { + const layer = useContext(HoverLayerContext); + const portal = useMemo(() => (layer ? { current: layer } : undefined), [layer]); + return ( + , zIndexRange: [1, 0] } : {})}> + {children} + + ); +}