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} + + ); +}