fix(ui): frame a shown Galaxy path and keep its edge labels off node names

Hand test K6. When the target of "Path to" lay close to the root, the
target's name covered the CALLS label and the camera did not move.

A newly shown path (not a step through it) now flies the camera onto
its nodes: the view direction stays, the camera steps in or out until
the path fills about half of the area beside the step list, with a
margin for names. All nodes of the path carry their name in the path
layer instead of the scene's name sprites, and each edge label slides
along its edge, then aside, until it touches no name and no other
label. Measured in the browser for JSONBAgg: zero overlaps for a one
hop and a two hop path, root and target 595 px apart (before: overlap
and an unmoved camera).

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-03 19:33:18 +02:00
1 parent 495e27a38d
commit 1ed66ce3e2
6 files changed
+405 -28

No files matched your search

+51 -7
View File
@@ -99,6 +99,10 @@
* 18. Neu (Review-Call 2026-10-02): die Prop `path`. Ein Pfad oder eine
* Aufrufreihe liegt mit beschrifteten Kanten ueber der Szene
* (src/galaxy/PathLayer.tsx). Ohne die Prop zeichnet die Szene wie vorher.
* 19. Neu (Handtest K6): ein neu gezeigter Pfad rahmt die Kamera auf seine
* Knoten (`PathFrame`, Rechnung in src/galaxy/path-frame.ts), mit Rand und
* neben der Schrittliste. Die Knoten des Pfades bekommen ihren Namen von der
* Pfadebene; die Namensebene der Szene zeichnet fuer sie keinen zweiten.
*/
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
@@ -113,7 +117,8 @@ import { HaloLayer } from './HaloLayer';
import { EdgeLines } from './EdgeLines';
import { NodeLabels } from './NodeLabels';
import { ScreenNodeSeparation } from './ScreenNodeSeparation';
import { PathLayer, type ScenePath } from './PathLayer';
import { PathLayer, pathNodeIds, type ScenePath } from './PathLayer';
import { pathFrame } from './path-frame';
import type { LabelBox } from './NodeLabels';
import { FRAME_MIN_DISTANCE, containShift, fitCamera, flatBounds, frameDistance, orthographicZoom } from './camera-frame';
import type { CameraFit, FrameBox } from './camera-frame';
@@ -437,6 +442,39 @@ function RootMarkers({ nodes }: { nodes: readonly GraphNode[] }): JSX.Element {
);
}
/*
* Aenderung 19: ein neuer Pfad bekommt eine Kamerafahrt auf seine Knoten.
*
* Nur wenn sich der Pfad selbst aendert (anderes Ziel, Aufrufreihe statt Pfad),
* nicht beim Durchschalten der Schritte: das ist ein Lesen im selben Bild. Die
* Blickrichtung bleibt, die Kamera tritt nur vor oder zurueck und rueckt den
* Pfad aus der Schrittliste links oben heraus.
*/
const PATH_PANEL_INSET = 440;
function PathFrame({ nodes, path, onTarget }: { nodes: readonly GraphNode[]; path: ScenePath; onTarget: (target: CameraTarget) => void }): null {
const camera = useThree((state) => state.camera);
const size = useThree((state) => state.size);
const key = `${path.labels}:${path.steps.map((step) => `${step.from}>${step.to}`).join(',')}`;
const latest = useRef({ nodes, size, onTarget });
latest.current = { nodes, size, onTarget };
useEffect(() => {
const { nodes: drawn, size: viewport, onTarget: report } = latest.current;
if (!(camera instanceof THREE.PerspectiveCamera) || viewport.height <= 0) return;
const ids = new Set(pathNodeIds(path));
const points = drawn.filter((node) => ids.has(node.id)).map((node) => ({ x: node.x, y: node.y, z: node.z }));
if (points.length === 0) return;
const direction = camera.getWorldDirection(new THREE.Vector3());
const frame = pathFrame(points, { direction, up: camera.up }, viewport, camera.fov,
{ left: Math.min(PATH_PANEL_INSET, viewport.width * 0.4), top: 0 });
report({ position: new THREE.Vector3(frame.position.x, frame.position.y, frame.position.z),
lookAt: new THREE.Vector3(frame.lookAt.x, frame.lookAt.y, frame.lookAt.z) });
// Nur ein neuer Pfad rahmt neu, siehe oben.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, camera]);
return null;
}
/* Bildratenmesser und Bildratendeckel (W10) */
/**
@@ -881,9 +919,14 @@ export function GraphScene({
const markedRoots = rootIds !== undefined && rootIds.size > 0 && rootIds.size <= ROOT_MARKER_LIMIT ? rootIds : undefined;
const rootNodes = useMemo(() => markedRoots ? renderedCode.filter((node) => markedRoots.has(node.id)) : [],
[markedRoots, renderedCode]);
// Der Ring traegt den Namen der Wurzel; ein zweiter Name darunter waere doppelt.
const labelNodes = useMemo(() => markedRoots ? renderedCode.filter((node) => !markedRoots.has(node.id)) : renderedCode,
[markedRoots, renderedCode]);
// Der Ring traegt den Namen der Wurzel, die Pfadebene die Namen ihrer Knoten (K6); ein zweiter Name waere doppelt.
const pathIds = useMemo(() => (path ? new Set(pathNodeIds(path)) : undefined), [path]);
const labelNodes = useMemo(() => markedRoots || pathIds
? renderedCode.filter((node) => !markedRoots?.has(node.id) && !pathIds?.has(node.id)) : renderedCode,
[markedRoots, pathIds, renderedCode]);
/* Das Kameraziel: das des Panels, oder die Fahrt auf einen neuen Pfad, je nachdem, was zuletzt kam. */
const [sceneTarget, setSceneTarget] = useState<CameraTarget | null>(cameraTarget);
useEffect(() => { setSceneTarget(cameraTarget); }, [cameraTarget]);
const onCodeHover = useCallback((node: GraphNode | null) => {
setHovered(node);
if (node) setHoveredShadow(null);
@@ -1023,7 +1066,8 @@ export function GraphScene({
)}
{landmarks && <HaloLayer nodes={renderedCode} />}
{rootNodes.length > 0 && <RootMarkers nodes={rootNodes} />}
{path !== undefined && <PathLayer nodes={renderedCode} path={path} />}
{path !== undefined && <PathLayer nodes={renderedCode} path={path} namedRoots={markedRoots} />}
{path !== undefined && !flat && <PathFrame nodes={renderedCode} path={path} onTarget={setSceneTarget} />}
{renderedShadow && renderedShadow.nodes.length > 0 && <group>
{drawEdges && <CoverageShadowEdges shadow={renderedShadow} brightness={display.edgeBrightness} />}
@@ -1035,8 +1079,8 @@ export function GraphScene({
{hovered && renderTooltip !== undefined && renderTooltip(hovered)}
{resolveCoverageShadowNode(renderedShadow, hoveredShadow) && hoveredShadow && renderShadowTooltip?.(hoveredShadow)}
<CameraAnimator target={cameraTarget} controlsRef={controlsRef} flat={flat} />
<FitContainment nodes={renderedNodes} target={cameraTarget} controlsRef={controlsRef} moved={moved}
<CameraAnimator target={sceneTarget} controlsRef={controlsRef} flat={flat} />
<FitContainment nodes={renderedNodes} target={sceneTarget} controlsRef={controlsRef} moved={moved}
enabled={separateNodes && !flat} />
{separateNodes && <ScreenNodeSeparation nodes={sceneNodes} active={active} onChange={receiveSeparation} onBusyChange={onRenderBusyChange}
pinned={markedRoots} />}
+89 -17
View File
@@ -5,14 +5,23 @@
* Farbe ihrer Art und ohne Tiefentest, damit sie im abgedunkelten Rest nicht
* verschwinden. Ihre Art steht als Beschriftung daneben, und nur an ihnen:
* eine Beschriftung an jeder Kante waere bei hundert Kanten kein Text mehr.
* Der Schritt, auf dem der Leser steht, ist heller und traegt einen Ring samt
* Namen an seinem Ziel. Gezeichnet wird an den Positionen, die die Szene wirklich
* Der Schritt, auf dem der Leser steht, ist heller und traegt einen Ring an
* seinem Ziel. Gezeichnet wird an den Positionen, die die Szene wirklich
* zeichnet, also nach der Trennung auf dem Schirm.
*
* Seit Handtest K6 tragen alle Knoten des Pfades ihren Namen hier, als DOM wie
* die Kantenlabels, und die Szene zeichnet fuer sie keinen zweiten. Die Labels
* der Kanten weichen diesen Namen aus: sie gleiten auf ihrer Kante, bis sie
* keinen Namen und kein anderes Label beruehren (src/galaxy/path-frame.ts).
* Vorher lag der Name des Ziels ueber "CALLS", sobald Ziel und Wurzel nah
* beieinander standen.
*/
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { Html } from '@react-three/drei';
import * as THREE from 'three';
import { edgeColor } from '../graph/edge-style';
import { placeAlongSegment, type ScreenRect } from './path-frame';
import type { ScopePathStep } from './scope-path';
import type { GraphNode } from './types';
@@ -23,7 +32,24 @@ export interface ScenePath {
labels: 'all' | 'active';
}
export function PathLayer({ nodes, path }: { nodes: readonly GraphNode[]; path: ScenePath }) {
/** Die Knoten eines Pfades, in der Reihenfolge, in der er sie betritt. */
export function pathNodeIds(path: ScenePath): number[] {
const ids = new Set<number>();
for (const step of path.steps) { ids.add(step.from); ids.add(step.to); }
return [...ids];
}
/** Welche Namen das Ausweichen beachtet: die des Pfades und die der markierten Wurzel. */
const NAME_SELECTOR = '.atlas-galaxy-path-node b, .atlas-galaxy-root-marker b';
/* Nur jedes dritte Bild: die Lage der Namen aendert sich mit der Kamera, nicht schneller. */
const PLACE_EVERY_FRAMES = 3;
export function PathLayer({ nodes, path, namedRoots }: {
nodes: readonly GraphNode[];
path: ScenePath;
/** Wurzeln, deren Name schon an ihrem Ring steht; sie bekommen hier keinen zweiten. */
namedRoots?: ReadonlySet<number> | undefined;
}) {
const byId = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const segments = useMemo(() => path.steps.flatMap((step, index) => {
const from = byId.get(step.from), to = byId.get(step.to);
@@ -45,25 +71,71 @@ export function PathLayer({ nodes, path }: { nodes: readonly GraphNode[]; path:
}, [segments, path.active]);
useEffect(() => () => geometry.dispose(), [geometry]);
const goal = segments.find((segment) => segment.index === path.active);
const labelled = useMemo(() => segments.filter(({ index }) => path.labels === 'all' || index === path.active), [segments, path.labels, path.active]);
const named = useMemo(() => pathNodeIds(path).filter((id) => !namedRoots?.has(id)).flatMap((id) => byId.get(id) ?? []),
[path, namedRoots, byId]);
const groups = useRef(new Map<number, THREE.Group>());
const labels = useRef(new Map<number, HTMLElement>());
const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl);
// drei hangs Html into the element the events are connected to, not into the canvas parent.
const connected = useThree((state) => state.events.connected) as unknown;
const tick = useRef(0);
const scratch = useMemo(() => ({ from: new THREE.Vector3(), to: new THREE.Vector3(), point: new THREE.Vector3() }), []);
useFrame(() => {
tick.current = (tick.current + 1) % PLACE_EVERY_FRAMES;
const host = connected instanceof HTMLElement ? connected : gl.domElement.parentElement;
if (tick.current !== 0 || host === null) return;
const box = gl.domElement.getBoundingClientRect();
const blockers: ScreenRect[] = [...host.querySelectorAll<HTMLElement>(NAME_SELECTOR)]
.map((element) => element.getBoundingClientRect()).filter((rect) => rect.width > 0)
.map((rect) => ({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }));
const toScreen = (node: GraphNode) => {
scratch.point.set(node.x, node.y, node.z).project(camera);
return { x: box.left + ((scratch.point.x + 1) / 2) * box.width, y: box.top + ((1 - scratch.point.y) / 2) * box.height };
};
const placed: ScreenRect[] = [];
for (const { index, from, to } of labelled) {
const group = groups.current.get(index), label = labels.current.get(index);
if (!group || !label || label.offsetWidth === 0) continue;
const a = toScreen(from), b = toScreen(to);
const choice = placeAlongSegment(a, b, { width: label.offsetWidth, height: label.offsetHeight }, blockers, placed);
placed.push(choice.rect);
group.position.lerpVectors(scratch.from.set(from.x, from.y, from.z), scratch.to.set(to.x, to.y, to.z), choice.t);
// The side step from placeAlongSegment, applied in screen space around the point on the edge.
const dx = (choice.rect.left + choice.rect.right) / 2, dy = (choice.rect.top + choice.rect.bottom) / 2;
const onEdge = { x: a.x + (b.x - a.x) * choice.t, y: a.y + (b.y - a.y) * choice.t };
label.style.transform = `translate(${dx - onEdge.x}px, ${dy - onEdge.y}px)`;
}
});
return (
<>
<lineSegments geometry={geometry} renderOrder={18}>
<lineBasicMaterial vertexColors transparent depthTest={false} depthWrite={false} toneMapped={false} />
</lineSegments>
{segments.filter(({ index }) => path.labels === 'all' || index === path.active).map(({ step, index, from, to }) => (
<Html key={index} position={[(from.x + to.x) / 2, (from.y + to.y) / 2, (from.z + to.z) / 2]} center
zIndexRange={[9, 0]} style={{ pointerEvents: 'none' }}>
<span className="atlas-galaxy-path-label" data-active={index === path.active}>{step.edge.type}</span>
</Html>
{labelled.map(({ step, index, from, to }) => (
<group key={index} position={[(from.x + to.x) / 2, (from.y + to.y) / 2, (from.z + to.z) / 2]}
ref={(group) => { if (group) groups.current.set(index, group); else groups.current.delete(index); }}>
<Html center zIndexRange={[9, 0]} style={{ pointerEvents: 'none' }}>
<span className="atlas-galaxy-path-label" data-active={index === path.active}
ref={(label) => { if (label) labels.current.set(index, label); else labels.current.delete(index); }}>{step.edge.type}</span>
</Html>
</group>
))}
{goal && (
<Html position={[goal.to.x, goal.to.y, goal.to.z]} zIndexRange={[10, 0]} style={{ pointerEvents: 'none' }}>
<span className="atlas-galaxy-path-step" data-testid="atlas-galaxy-path-step" data-node={goal.to.id}>
<i aria-hidden="true" />
<b>{goal.to.name}</b>
</span>
</Html>
)}
{named.map((node) => {
const active = node.id === goal?.to.id;
return (
<Html key={node.id} position={[node.x, node.y, node.z]} zIndexRange={[10, 0]} style={{ pointerEvents: 'none' }}>
<span className="atlas-galaxy-path-node" data-active={active} data-node={node.id}
{...(active ? { 'data-testid': 'atlas-galaxy-path-step' } : {})}>
<i aria-hidden="true" />
<b>{node.name}</b>
</span>
</Html>
);
})}
</>
);
}
+6 -3
View File
@@ -95,9 +95,12 @@
.atlas-galaxy-path-panel footer button:disabled { opacity:.4; cursor:not-allowed; }
.atlas-galaxy-path-label { display:block; padding:0 4px; border-radius:3px; background:rgba(13, 15, 18, .78); color:#cfe0d6; font:600 10px/1.4 var(--atlas-mono, ui-monospace, monospace); white-space:nowrap; }
.atlas-galaxy-path-label[data-active=true] { color:#ffffff; outline:1px solid #80ac96; }
.atlas-galaxy-path-step { position:relative; display:block; width:0; height:0; pointer-events:none; }
.atlas-galaxy-path-step > i { position:absolute; left:-12px; top:-12px; width:24px; height:24px; box-sizing:border-box; border:2px solid #9fe0bd; border-radius:50%; box-shadow:0 0 12px rgba(159, 224, 189, .55); }
.atlas-galaxy-path-step > b { position:absolute; bottom:15px; left:0; transform:translateX(-50%); padding:1px 6px; border-radius:4px; background:rgba(13, 15, 18, .84); color:#cfeedd; font:600 12px/1.4 var(--atlas-ui-font, system-ui, sans-serif); white-space:nowrap; }
/* Jeder Knoten des Pfades traegt seinen Namen (K6); der aktuelle Schritt dazu den hellen Ring. */
.atlas-galaxy-path-node { position:relative; display:block; width:0; height:0; pointer-events:none; }
.atlas-galaxy-path-node > i { position:absolute; left:-8px; top:-8px; width:16px; height:16px; box-sizing:border-box; border:1px solid rgba(159, 224, 189, .55); border-radius:50%; }
.atlas-galaxy-path-node[data-active=true] > i { left:-12px; top:-12px; width:24px; height:24px; border:2px solid #9fe0bd; box-shadow:0 0 12px rgba(159, 224, 189, .55); }
.atlas-galaxy-path-node > b { position:absolute; bottom:15px; left:0; transform:translateX(-50%); padding:1px 6px; border-radius:4px; background:rgba(13, 15, 18, .84); color:#cfeedd; font:600 12px/1.4 var(--atlas-ui-font, system-ui, sans-serif); white-space:nowrap; }
.atlas-galaxy-path-node:not([data-active=true]) > b { color:#b4cbbf; font-weight:500; }
/* Die Wurzel eines Scopes: ein Ring auf dem Punkt und ihr Name darunter, immer sichtbar. */
.atlas-galaxy-root-marker { position:relative; display:block; width:0; height:0; pointer-events:none; }
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { PATH_FRAME_MARGIN, pathFrame, placeAlongSegment, type ScreenRect } from './path-frame';
/* A camera looking down -z with y up, as the scene sets it; projection by hand. */
function project(point: { x: number; y: number; z: number }, frame: ReturnType<typeof pathFrame>, viewport: { width: number; height: number }, fov: number) {
const depth = frame.position.z - point.z;
const half = Math.tan((fov * Math.PI) / 360) * depth;
const aspect = viewport.width / viewport.height;
return { x: ((point.x - frame.position.x) / (half * aspect) + 1) / 2 * viewport.width, y: (1 - (point.y - frame.position.y) / half) / 2 * viewport.height };
}
describe('hand test K6: the camera frames a shown path', () => {
const view = { direction: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
const viewport = { width: 1170, height: 800 };
it('zooms in on a short path, so root and target stand far apart on screen and inside the free area', () => {
const root = { x: 0, y: 0, z: 0 }, target = { x: 12, y: 4, z: 0 };
const frame = pathFrame([root, target], view, viewport, 50, { left: 440, top: 0 });
const a = project(root, frame, viewport, 50), b = project(target, frame, viewport, 50);
expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(250);
for (const point of [a, b]) {
expect(point.x).toBeGreaterThanOrEqual(440 + PATH_FRAME_MARGIN.x - 1);
expect(point.x).toBeLessThanOrEqual(viewport.width - PATH_FRAME_MARGIN.x + 1);
expect(point.y).toBeGreaterThanOrEqual(PATH_FRAME_MARGIN.y - 1);
expect(point.y).toBeLessThanOrEqual(viewport.height - PATH_FRAME_MARGIN.y + 1);
}
});
it('steps back for a long path until every node fits, keeping the view direction', () => {
const points = [{ x: -400, y: 0, z: 0 }, { x: 0, y: 250, z: 0 }, { x: 380, y: -200, z: 0 }];
const frame = pathFrame(points, view, viewport, 50, { left: 0, top: 0 });
expect(frame.position.z).toBeGreaterThan(400);
expect(frame.position.x).toBeCloseTo(frame.lookAt.x);
for (const point of points.map(entry => project(entry, frame, viewport, 50))) {
expect(point.x).toBeGreaterThanOrEqual(PATH_FRAME_MARGIN.x - 1);
expect(point.x).toBeLessThanOrEqual(viewport.width - PATH_FRAME_MARGIN.x + 1);
}
});
it('never moves closer than the minimum distance for a single point', () => {
const frame = pathFrame([{ x: 5, y: 5, z: 5 }], view, viewport, 50, { left: 0, top: 0 });
expect(frame.position.z - 5).toBeGreaterThanOrEqual(30);
});
});
describe('hand test K6: edge labels avoid node names', () => {
const size = { width: 40, height: 14 };
const overlaps = (a: ScreenRect, b: ScreenRect) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top);
it('keeps the midpoint when nothing is in the way', () => {
expect(placeAlongSegment({ x: 0, y: 0 }, { x: 200, y: 0 }, size, [], []).t).toBe(0.5);
});
it('slides along the edge away from a name that covers the midpoint', () => {
const name = { left: 70, right: 130, top: -10, bottom: 10 };
const placed = placeAlongSegment({ x: 0, y: 0 }, { x: 200, y: 0 }, size, [name], []);
expect(placed.overlap).toBe(0);
expect(overlaps(placed.rect, name)).toBe(false);
expect(placed.side).toBe(0);
expect(placed.t).not.toBe(0.5);
});
it('steps aside from the edge when every point along it is covered, and avoids labels placed before', () => {
const wide = { left: -20, right: 220, top: -9, bottom: 9 };
const placed = placeAlongSegment({ x: 0, y: 0 }, { x: 200, y: 0 }, size, [wide], []);
expect(placed.overlap).toBe(0);
expect(overlaps(placed.rect, wide)).toBe(false);
const second = placeAlongSegment({ x: 0, y: 0 }, { x: 200, y: 0 }, size, [], [placed.rect]);
expect(overlaps(second.rect, placed.rect)).toBe(false);
});
it('steps far enough aside on a short steep edge between two names (Func and Aggregate in django-demo)', () => {
const from = { x: 1224, y: 618 }, to = { x: 1197, y: 536 };
const names = [{ left: 1170, right: 1226, top: 506, bottom: 526 }, { left: 1185, right: 1264, top: 578, bottom: 598 }];
const placed = placeAlongSegment(from, to, { width: 62, height: 15 }, names, []);
expect(placed.overlap).toBe(0);
expect(names.some(name => overlaps(placed.rect, name))).toBe(false);
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Ein gezeigter Pfad wird gerahmt, und seine Kantenlabels weichen den Namen
* aus (Handtest K6).
*
* Bis hierher blieb die Kamera beim Anzeigen eines Pfades stehen. Lagen Ziel
* und Wurzel nah beieinander, standen ihre Namen und das Label der Kante auf
* wenigen Pixeln, und der Name des Ziels deckte "CALLS" zu. Zwei reine
* Rechnungen, ohne three und ohne DOM, damit sie ohne Browser pruefbar sind:
*
* - {@link pathFrame}: wohin die Kamera fliegt. Sie behaelt ihre Blickrichtung
* (ein Pfad ist keine neue Ansicht, nur ein naeherer Ausschnitt) und tritt so
* weit vor oder zurueck, dass alle Knoten des Pfades mit Rand im freien Teil
* der Flaeche stehen; links liegt die Schrittliste.
* - {@link placeAlongSegment}: wo ein Kantenlabel steht. Es gleitet auf seiner
* Kante, bis es keinen Namen und kein anderes Label mehr beruehrt, und tritt
* erst dann seitlich neben die Kante.
*/
export interface Vec3 { x: number; y: number; z: number }
export interface ScreenRect { left: number; right: number; top: number; bottom: number }
/** Der Rand um den gerahmten Pfad, in Pixeln: Namen stehen neben und ueber ihrem Knoten. */
export const PATH_FRAME_MARGIN = { x: 140, y: 70 };
/** Naeher kommt die Kamera nicht, auch nicht fuer einen Pfad aus zwei fast gleichen Punkten. */
export const PATH_FRAME_MIN_DISTANCE = 30;
/*
* Hoechstens so viel der freien Flaeche, von der Mitte aus gemessen: der Pfad
* fuellt etwa die Haelfte, der Rest seiner Nachbarschaft bleibt sichtbar. Ohne
* diese Grenze stand ein Pfad aus einem Hop ueber die ganze Breite.
*/
export const PATH_FRAME_FILL = 0.25;
const sub = (a: Vec3, b: Vec3): Vec3 => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
const dot = (a: Vec3, b: Vec3) => a.x * b.x + a.y * b.y + a.z * b.z;
const cross = (a: Vec3, b: Vec3): Vec3 => ({ x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x });
const scale = (a: Vec3, factor: number): Vec3 => ({ x: a.x * factor, y: a.y * factor, z: a.z * factor });
const add = (...vectors: Vec3[]): Vec3 => vectors.reduce((sum, value) => ({ x: sum.x + value.x, y: sum.y + value.y, z: sum.z + value.z }), { x: 0, y: 0, z: 0 });
const unit = (a: Vec3): Vec3 => { const length = Math.hypot(a.x, a.y, a.z) || 1; return scale(a, 1 / length); };
/**
* Das Kameraziel fuer einen Pfad.
*
* @param inset der verdeckte Rand der Flaeche in Pixeln (die Schrittliste links oben)
* @returns Blickpunkt, Kameraposition und die Pixel je Welteinheit am Pfad
*/
export function pathFrame(points: readonly Vec3[], view: { direction: Vec3; up: Vec3 }, viewport: { width: number; height: number },
fovDegrees: number, inset: { left: number; top: number }): { lookAt: Vec3; position: Vec3; pixelsPerUnit: number } {
const direction = unit(view.direction);
const right = unit(cross(direction, view.up));
const up = cross(right, direction);
const center = scale(add(...points), 1 / Math.max(1, points.length));
const tangent = Math.tan((fovDegrees * Math.PI) / 360);
const freeWidth = Math.max(1, viewport.width - inset.left), freeHeight = Math.max(1, viewport.height - inset.top);
const halfX = Math.max(1, Math.min(freeWidth / 2 - PATH_FRAME_MARGIN.x, freeWidth * PATH_FRAME_FILL));
const halfY = Math.max(1, Math.min(freeHeight / 2 - PATH_FRAME_MARGIN.y, freeHeight * PATH_FRAME_FILL));
let pixelsPerUnit = viewport.height / (2 * PATH_FRAME_MIN_DISTANCE * tangent);
for (const point of points) {
const offset = sub(point, center);
const x = Math.abs(dot(offset, right)), y = Math.abs(dot(offset, up));
if (x > 1e-6) pixelsPerUnit = Math.min(pixelsPerUnit, halfX / x);
if (y > 1e-6) pixelsPerUnit = Math.min(pixelsPerUnit, halfY / y);
}
const distance = viewport.height / (2 * pixelsPerUnit * tangent);
// Der Pfad steht in der Mitte des FREIEN Teils, nicht der ganzen Flaeche.
const shiftX = inset.left + freeWidth / 2 - viewport.width / 2, shiftY = inset.top + freeHeight / 2 - viewport.height / 2;
const lookAt = add(center, scale(right, -shiftX / pixelsPerUnit), scale(up, shiftY / pixelsPerUnit));
return { lookAt, position: sub(lookAt, scale(direction, distance)), pixelsPerUnit };
}
const LABEL_STEPS = [0.5, 0.4, 0.6, 0.3, 0.7, 0.2, 0.8];
const LABEL_PAD = 2;
function overlapArea(a: ScreenRect, b: ScreenRect): number {
const width = Math.min(a.right, b.right + LABEL_PAD) - Math.max(a.left, b.left - LABEL_PAD);
const height = Math.min(a.bottom, b.bottom + LABEL_PAD) - Math.max(a.top, b.top - LABEL_PAD);
return width > 0 && height > 0 ? width * height : 0;
}
/**
* Wo das Label einer Kante steht: der erste Punkt auf der Kante, an dem es
* nichts beruehrt, sonst daneben, sonst der Platz mit der kleinsten
* Ueberdeckung. `t` ist der Anteil auf der Kante, `side` der seitliche Versatz
* in Labelhoehen.
*/
export function placeAlongSegment(from: { x: number; y: number }, to: { x: number; y: number }, size: { width: number; height: number },
blockers: readonly ScreenRect[], placed: readonly ScreenRect[]): { t: number; side: number; rect: ScreenRect; overlap: number } {
const length = Math.hypot(to.x - from.x, to.y - from.y) || 1;
const normal = { x: -(to.y - from.y) / length, y: (to.x - from.x) / length };
// One side step clears the label's own extent across the edge: its height on a flat edge, its width on a steep one.
const step = (size.width / 2) * Math.abs(normal.x) + (size.height / 2) * Math.abs(normal.y) + 6;
let best: { t: number; side: number; rect: ScreenRect; overlap: number } | undefined;
for (const side of [0, 1, -1, 2, -2, 3, -3]) {
for (const t of LABEL_STEPS) {
const offset = side * step;
const x = from.x + (to.x - from.x) * t + normal.x * offset, y = from.y + (to.y - from.y) * t + normal.y * offset;
const rect = { left: x - size.width / 2, right: x + size.width / 2, top: y - size.height / 2, bottom: y + size.height / 2 };
const overlap = [...blockers, ...placed].reduce((sum, other) => sum + overlapArea(rect, other), 0);
if (overlap === 0) return { t, side, rect, overlap };
if (!best || overlap < best.overlap) best = { t, side, rect, overlap };
}
}
return best!;
}
+77 -1
View File
@@ -481,8 +481,84 @@ async function checkK13(page) {
}
/* ------------------------------------------------------------------ */
/* K6: Kamera auf den Pfad, Kantenlabels frei von Namen */
const CHECKS = { K9: checkK9, K2: checkK2, K8: checkK8, K3: checkK3, K13: checkK13 };
async function pickPath(page, name) {
const picker = page.locator('details.atlas-graph-path-picker').first();
await picker.locator('summary').click();
const search = page.getByRole('searchbox', { name: 'Find a path target' });
await search.fill('');
await search.type(name, { delay: 15 });
await page.locator('.atlas-graph-path-menu button', { has: page.locator('strong', { hasText: new RegExp(`^${name}$`) }) }).first().click();
}
async function pathLayout(page) {
return page.evaluate(() => {
const rect = (el) => { const r = el.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, text: el.textContent?.trim() ?? '' }; };
const labels = [...document.querySelectorAll('.atlas-galaxy-path-label')].map(rect).filter((r) => r.right > r.left);
const names = [...document.querySelectorAll('.atlas-galaxy-path-node b, .atlas-galaxy-root-marker b')].map(rect).filter((r) => r.right > r.left);
const rings = [...document.querySelectorAll('.atlas-galaxy-path-node > i, .atlas-galaxy-root-marker > i')].map(rect);
const hit = (a, b) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top);
const overlaps = [];
for (const label of labels) for (const name of names) if (hit(label, name)) overlaps.push(`${label.text} x ${name.text}`);
for (let i = 0; i < labels.length; i += 1) for (let j = i + 1; j < labels.length; j += 1) if (hit(labels[i], labels[j])) overlaps.push(`${labels[i].text} x ${labels[j].text}`);
const canvas = document.querySelector('.atlas-galaxy canvas')?.getBoundingClientRect();
const panel = document.querySelector('[data-testid="atlas-galaxy-path-panel"]')?.getBoundingClientRect();
const centres = rings.map((r) => ({ x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2 }));
let spread = 0;
for (let i = 0; i < centres.length; i += 1) for (let j = i + 1; j < centres.length; j += 1) spread = Math.max(spread, Math.hypot(centres[i].x - centres[j].x, centres[i].y - centres[j].y));
const inside = canvas ? centres.every((c) => c.x > canvas.left && c.x < canvas.right && c.y > canvas.top && c.y < canvas.bottom) : false;
const underPanel = panel ? centres.filter((c) => c.x > panel.left && c.x < panel.right && c.y > panel.top && c.y < panel.bottom).length : 0;
const box = (r) => `${r.text}@${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.right)},${Math.round(r.bottom)}`;
return { labels: labels.length, names: names.map((n) => n.text), overlaps, spreadPx: Math.round(spread), inside, underPanel,
boxes: overlaps.length ? [...labels, ...names].map(box) : undefined,
camera: globalThis.__atlasGalaxyFit?.measure?.().camera.position.map((v) => Math.round(v)) ?? null };
});
}
async function checkK6(page) {
await section(page, 'K6', 'Showing a path flies the camera onto its nodes, and no edge label lies under a node name');
await open(page);
await select(page, ROOT);
await settled(page);
const target = 'test_jsonb_agg_charfield_order_by';
const before = await pathLayout(page);
await shot(page, 'K6', 'before-path', `${ROOT} at 1 layer before Path to (camera ${JSON.stringify(before.camera)})`);
await pickPath(page, target);
const frames = [];
for (const [label, ms] of [['0ms', 0], ['250ms', 250], ['1s', 750], ['3s', 2000]]) {
await wait(ms);
frames.push({ at: label, ...await pathLayout(page) });
await shot(page, 'K6', `path-1hop-${label}`, `${label} after Path to ${target}: spread ${frames.at(-1).spreadPx} px, ${frames.at(-1).overlaps.length} label overlaps`);
}
const oneHop = frames.at(-1);
await check('K6', `Path to ${target} (1 hop): camera moved, path nodes far apart and in view, no edge label under a name`,
JSON.stringify(oneHop.camera) !== JSON.stringify(before.camera) && oneHop.spreadPx >= 200 && oneHop.inside && oneHop.underPanel === 0
&& oneHop.overlaps.length === 0 && oneHop.labels > 0, { before: before.camera, frames });
// Ein laengerer Pfad bei zwei Ebenen.
await page.keyboard.press('Escape');
await wait(400);
await expand(page);
await pickPath(page, 'Func');
await wait(3000);
const twoHops = await pathLayout(page);
const heading = await page.locator('[data-testid="atlas-galaxy-path-panel"] strong').first().innerText().catch(() => '');
await shot(page, 'K6', 'path-func', `${heading}: spread ${twoHops.spreadPx} px, ${twoHops.overlaps.length} overlaps`);
for (let step = 0; step < 2; step += 1) {
await page.locator('[data-testid="atlas-galaxy-path-panel"]').getByRole('button', { name: 'Next' }).click().catch(() => {});
await wait(700);
}
const stepped = await pathLayout(page);
await shot(page, 'K6', 'path-func-stepped', `After stepping: ${stepped.overlaps.length} overlaps, camera ${JSON.stringify(stepped.camera)}`);
await check('K6', 'A two-hop path is framed too, and stepping keeps labels clear of names', twoHops.overlaps.length === 0 && stepped.overlaps.length === 0
&& twoHops.inside && twoHops.underPanel === 0 && /Path to Func/.test(heading), { heading, twoHops, stepped });
await page.keyboard.press('Escape');
}
/* ------------------------------------------------------------------ */
const CHECKS = { K9: checkK9, K2: checkK2, K8: checkK8, K3: checkK3, K13: checkK13, K6: checkK6 };
await mkdir(OUT, { recursive: true });
const context = await chromium.launchPersistentContext(PROFILE, {