From 6980f6d5d3da178e5cb101b549b26f4e949f5e81 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 14:47:55 +0200 Subject: [PATCH] fix(ui): leave out a path label that finds no free spot When a path label found no free spot along its edge, it was drawn where it covered the least, and that could still be the hierarchy band title (a wide title across a steep edge): both texts were unreadable. The hierarchy edge labels already leave out a label in that case. Path labels now do the same. The edge type stays in the step list, and the label comes back as soon as the camera gives it a free spot. A later label keeps clear of the earlier ones as before. Signed-off-by: Bernhard Jackiewicz --- graph-ui/src/galaxy/PathLayer.tsx | 56 +++++++++++++++++---- graph-ui/src/galaxy/path-label-spot.test.ts | 48 ++++++++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 graph-ui/src/galaxy/path-label-spot.test.ts diff --git a/graph-ui/src/galaxy/PathLayer.tsx b/graph-ui/src/galaxy/PathLayer.tsx index 0e12f147e..ce8eeca64 100644 --- a/graph-ui/src/galaxy/PathLayer.tsx +++ b/graph-ui/src/galaxy/PathLayer.tsx @@ -57,6 +57,31 @@ export function screenBlockers(host: ParentNode): ScreenRect[] { /* Nur jedes dritte Bild: die Lage der Namen aendert sich mit der Kamera, nicht schneller. */ const PLACE_EVERY_FRAMES = 3; +/** + * Wo die Kantenlabels des Pfades stehen, in der Reihenfolge des Pfades, und + * welches keinen Platz bekommt. + * + * Review zu K30: fand ein Label keinen freien Platz, stand es dort, wo es am + * wenigsten verdeckte, und das konnte die Ueberschrift des Bandes der + * Hierarchie sein (ein breiter Titel ueber einer steilen Kante). Wie die + * Kantenschilder der Hierarchie (HierarchyEdgeLabels.tsx) faellt es jetzt + * weg, statt zwei Texte unlesbar zu machen: die Art der Kante steht weiter in + * der Schrittliste, und mit der naechsten Kamera kommt es wieder, sobald es + * frei steht. Ein spaeteres Label weicht den frueheren aus wie den Namen. + */ +export function pathLabelSpots(labels: readonly { index: number; from: { x: number; y: number }; to: { x: number; y: number }; size: { width: number; height: number } }[], + blockers: readonly ScreenRect[]): Map { + const placed: ScreenRect[] = []; + const spots = new Map(); + for (const { index, from, to, size } of labels) { + const choice = placeAlongSegment(from, to, size, blockers, placed); + if (choice.overlap > 0) continue; + placed.push(choice.rect); + spots.set(index, { t: choice.t, rect: choice.rect }); + } + return spots; +} + export function PathLayer({ nodes, path, namedRoots }: { nodes: readonly GraphNode[]; path: ScenePath; @@ -90,6 +115,8 @@ export function PathLayer({ nodes, path, namedRoots }: { const groups = useRef(new Map()); const labels = useRef(new Map()); + // Die Groesse eines Labels nach seinem Text: ein weggelassenes Label (display: none) hat keine, die man messen koennte. + const sizes = useRef(new Map()); 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. @@ -106,17 +133,28 @@ export function PathLayer({ nodes, path, namedRoots }: { 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 measured = labelled.flatMap(({ step, index, from, to }) => { 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); + if (!group || !label) return []; + let size = sizes.current.get(step.edge.type); + if (!size) { + label.style.display = ''; + size = { width: label.offsetWidth, height: label.offsetHeight }; + if (size.width === 0) return []; + sizes.current.set(step.edge.type, size); + } + return [{ index, group, label, from, to, a: toScreen(from), b: toScreen(to), size }]; + }); + const spots = pathLabelSpots(measured.map(({ index, a, b, size }) => ({ index, from: a, to: b, size })), blockers); + for (const { index, group, label, from, to, a, b } of measured) { + const spot = spots.get(index); + const display = spot ? '' : 'none'; + if (label.style.display !== display) label.style.display = display; + if (!spot) continue; + group.position.lerpVectors(scratch.from.set(from.x, from.y, from.z), scratch.to.set(to.x, to.y, to.z), spot.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 }; + const dx = (spot.rect.left + spot.rect.right) / 2, dy = (spot.rect.top + spot.rect.bottom) / 2; + const onEdge = { x: a.x + (b.x - a.x) * spot.t, y: a.y + (b.y - a.y) * spot.t }; label.style.transform = `translate(${dx - onEdge.x}px, ${dy - onEdge.y}px)`; } }); diff --git a/graph-ui/src/galaxy/path-label-spot.test.ts b/graph-ui/src/galaxy/path-label-spot.test.ts new file mode 100644 index 000000000..d7c86230a --- /dev/null +++ b/graph-ui/src/galaxy/path-label-spot.test.ts @@ -0,0 +1,48 @@ +/* + * Review zu K30: "ueberlappen nie" galt fuer die Kantenlabels des Pfades nicht. + * Fand ein Label keinen freien Platz, nahm placeAlongSegment den mit der + * kleinsten Ueberdeckung, und PathLayer zeichnete es dort, auch mitten auf der + * Ueberschrift des Bandes. Die Kantenschilder der Hierarchie fallen in diesem + * Fall weg; die Labels des Pfades jetzt auch. Die Art jeder Kante steht + * weiter in der Schrittliste. + */ +import { expect, it } from 'vitest'; +import { pathLabelSpots } from './PathLayer'; +import type { ScreenRect } from './path-frame'; + +const overlaps = (a: ScreenRect, b: ScreenRect) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top); + +// Measured in the browser (CSS px): JSONBAgg <-DEFINES- general.py -DEFINES-> __init__ at two layers of the hierarchy. +const JSONBAGG = { x: 1050, y: 368 }, GENERAL = { x: 808, y: 572 }, INIT = { x: 1160, y: 757 }; +const NAMES: ScreenRect[] = [{ left: 1018, right: 1082, top: 336, bottom: 353 }, { left: 778, right: 838, top: 540, bottom: 557 }, { left: 1135, right: 1185, top: 725, bottom: 742 }]; +const LABEL = { width: 50, height: 14 }; +const edges = [{ index: 0, from: GENERAL, to: JSONBAGG, size: LABEL }, { index: 1, from: GENERAL, to: INIT, size: LABEL }]; + +it('K30: a path label that finds no free spot on the band title is left out, not drawn on the title', () => { + // A band title that covers the edge from general.py to __init__ from end to end (the title of the browser run, made larger). + const title = { left: 512, right: 1590, top: 503, bottom: 835 }; + const spots = pathLabelSpots(edges, [...NAMES, title]); + expect(spots.get(1)).toBeUndefined(); + // The other edge runs above the title and keeps its label, clear of every name and of the title. + const kept = spots.get(0); + expect(kept).toBeDefined(); + for (const blocker of [...NAMES, title]) expect(overlaps(kept!.rect, blocker)).toBe(false); +}); + +it('K30: with the title at its real size both labels stand, and none touches a name, the title or the other label', () => { + const title = { left: 834, right: 1268, top: 652, bottom: 687 }; + const spots = pathLabelSpots(edges, [...NAMES, title]); + const rects = [spots.get(0)?.rect, spots.get(1)?.rect]; + expect(rects.every(Boolean)).toBe(true); + for (const rect of rects) for (const blocker of [...NAMES, title]) expect(overlaps(rect!, blocker)).toBe(false); + expect(overlaps(rects[0]!, rects[1]!)).toBe(false); +}); + +it('K30: a later label also keeps clear of an earlier one, and gives way when only its place is left', () => { + // Two labels on the same short edge: the second has nowhere to go but onto the first. + const short = [{ index: 0, from: { x: 100, y: 100 }, to: { x: 130, y: 100 }, size: LABEL }, { index: 1, from: { x: 100, y: 100 }, to: { x: 130, y: 100 }, size: LABEL }]; + const walls = [{ left: 0, right: 400, top: 0, bottom: 88 }, { left: 0, right: 400, top: 112, bottom: 300 }, { left: 0, right: 56, top: 0, bottom: 300 }, { left: 174, right: 400, top: 0, bottom: 300 }]; + const spots = pathLabelSpots(short, walls); + expect(spots.get(0)).toBeDefined(); + expect(spots.get(1)).toBeUndefined(); +});