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