From eee4063c43e619a33c48fd0408bb9dc2dd45cc92 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 23:02:18 -0700 Subject: [PATCH 01/19] feat(graph): define shared geometry for item wire paths --- docs/architecture/schematic-connections.md | 25 +++ .../lib/__tests__/edgeRouteGeometry.test.ts | 177 +++++++++++++++ packages/web/src/lib/edgeRouteGeometry.ts | 204 ++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts create mode 100644 packages/web/src/lib/edgeRouteGeometry.ts diff --git a/docs/architecture/schematic-connections.md b/docs/architecture/schematic-connections.md index 9b140821..602785c4 100644 --- a/docs/architecture/schematic-connections.md +++ b/docs/architecture/schematic-connections.md @@ -115,3 +115,28 @@ spacing, readable names or a useful hierarchy. This is a reviewable candidate path. It does not close dynamic LOD, hosted parity, release/deployment work, or the remaining item-level visual problems. + +## Detailed item wiring continuation (#220) + +The first item-wiring increment starts from dev +`007148f1a526b8ef028222a429b346a67e5c3c2c`. `edgeRouteGeometry.ts` defines the +shared display path, final-segment arrow, distance along the route and pointer +projection needed by the D3 path/hitbox/arrow/label consumers. Consecutive +coincident points do not produce broken paths; a nonzero closed loop retains +its travel and direction. Label placement fits an entire label on one segment, +keeps text upright, retains original icon direction and returns no placement +when cards or previous labels leave no clear slot. Equal-distance pointer hits +prefer the prior leg so dragging does not jump across a nearby return wire. + +This foundation is not yet connected to the item renderer and does not establish +routed item acceptance. Next, adapt all four geometry consumers together, then +add bounded routing/admission, named boundary connections, exact deferred counts +and accessible original relationships. Routing and obstacle searches belong to +bounded updates after layout or scope changes, not every camera/simulation tick. +Preserve edit/Grow, pointer hitboxes, label dragging and canonical positions. + +On 2026-10-28, Node22.18 ran the nine pure geometry cases serially in the existing +clean-installed partial probe: 9 passed, no skips, 0.69s (8ms test bodies). +Cases cover elbows, reversed segments, closed loops, duplicate/invalid points, +nearest-leg dragging, ties, long labels and rotated obstacle footprints. This +is scoped helper evidence, not a full-tree browser/database/performance result. diff --git a/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts new file mode 100644 index 00000000..ff05a127 --- /dev/null +++ b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { + createEdgeRoute, + edgeRouteLabel, + pointOnEdgeRoute, + projectEdgeRoutePointer, +} from '../edgeRouteGeometry'; + +const elbow = () => + createEdgeRoute([ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 100 }, + ])!; + +describe('item wire geometry', () => { + it('uses the actual final segment for the target arrow and distance along every bend', () => { + const route = elbow(); + expect(route.path).toBe('M0,0 L200,0 L200,100'); + expect(route.target).toEqual({ x: 200, y: 100, angle: 90 }); + expect(pointOnEdgeRoute(route, 0.5)).toEqual({ + x: 150, + y: 0, + angle: 0, + t: 0.5, + }); + expect(pointOnEdgeRoute(route, 5 / 6)).toEqual({ + x: 200, + y: 50, + angle: 90, + t: 5 / 6, + }); + expect(pointOnEdgeRoute(route, 2)).toMatchObject({ + x: 200, + y: 100, + angle: 90, + }); + expect(pointOnEdgeRoute(route, -1)).toMatchObject({ x: 0, y: 0, angle: 0 }); + }); + + it('removes duplicate/straight knots without mutating source coordinates or removing a return leg', () => { + const points = [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 40, y: 0 }, + { x: 100, y: 0 }, + { x: 40, y: 0 }, + ]; + const before = structuredClone(points); + const route = createEdgeRoute(points)!; + expect(route.path).toBe('M0,0 L100,0 L40,0'); + expect(route.length).toBe(160); + expect(route.target.angle).toBe(180); + expect(points).toEqual(before); + expect(route.points[0]).not.toBe(points[0]); + }); + + it('keeps self-loop travel and original direction when endpoints coincide', () => { + const points = [ + { x: 10, y: 10 }, + { x: 110, y: 10 }, + { x: 110, y: 110 }, + { x: 10, y: 110 }, + { x: 10, y: 10 }, + ]; + const route = createEdgeRoute(points)!; + expect(route.length).toBe(400); + expect(route.target).toEqual({ x: 10, y: 10, angle: -90 }); + expect(pointOnEdgeRoute(route, 0.5)).toMatchObject({ x: 110, y: 110 }); + expect(createEdgeRoute([...points].reverse())!.target.angle).toBe(180); + }); + + it('rejects unpaintable or nonfinite routes instead of emitting broken paths/hitboxes', () => { + for (const points of [ + [], + [{ x: 4, y: 4 }], + [ + { x: 4, y: 4 }, + { x: 4, y: 4 }, + ], + [ + { x: 0, y: 0 }, + { x: NaN, y: 1 }, + ], + [ + { x: -1e308, y: 0 }, + { x: 1e308, y: 0 }, + ], + ]) { + expect(createEdgeRoute(points)).toBeNull(); + } + }); + + it('slides against the nearest routed segment, not the source-target diagonal', () => { + const route = elbow(); + expect(projectEdgeRoutePointer(route, { x: 210, y: 60 })).toBeCloseTo( + 260 / 300 + ); + expect(projectEdgeRoutePointer(route, { x: 90, y: -20 })).toBeCloseTo( + 90 / 300 + ); + expect(projectEdgeRoutePointer(route, { x: -100, y: 0 })).toBe(0); + expect(projectEdgeRoutePointer(route, { x: 200, y: 200 })).toBe(1); + }); + + it('keeps a dragged label on its prior leg when two nearby legs are equally close', () => { + const route = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 40 }, + { x: 0, y: 40 }, + ])!; + expect(projectEdgeRoutePointer(route, { x: 100, y: 20 }, 0.2)).toBeCloseTo( + 100 / 440 + ); + expect(projectEdgeRoutePointer(route, { x: 100, y: 20 }, 0.8)).toBeCloseTo( + 340 / 440 + ); + expect(projectEdgeRoutePointer(route, { x: NaN, y: 0 }, 0.8)).toBe(0.8); + }); + + it('fits whole labels on a segment away from bends and retains original arrow orientation', () => { + const route = createEdgeRoute([ + { x: 200, y: 100 }, + { x: 0, y: 100 }, + { x: 0, y: 0 }, + ])!; + const label = edgeRouteLabel(route, { + dims: { width: 80, height: 20 }, + t: 0.65, + })!; + expect(label.rotation).toBe(0); + expect(label.iconRotation).toBe(180); + expect(label.x).toBeGreaterThanOrEqual(48); + expect(label.y).toBe(78); + expect(label.t).toBeCloseTo(152 / 300); + }); + + it('moves labels away from cards/earlier labels and explicitly defers when none fit', () => { + const route = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 400, y: 0 }, + ])!; + const dims = { width: 80, height: 20 }; + const label = edgeRouteLabel(route, { + dims, + obstacles: [{ x: 200, y: -22, width: 140, height: 40 }], + })!; + expect(Math.abs(label.x - 200)).toBeGreaterThanOrEqual(118); + expect( + edgeRouteLabel(route, { + dims, + obstacles: [{ x: 200, y: -22, width: 1000, height: 1000 }], + }) + ).toBeNull(); + expect( + edgeRouteLabel(elbow(), { dims: { width: 250, height: 20 } }) + ).toBeNull(); + }); + + it('uses the rotated label footprint when avoiding obstacles on a vertical segment', () => { + const route = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 0, y: 400 }, + ])!; + const label = edgeRouteLabel(route, { + dims: { width: 100, height: 20 }, + obstacles: [{ x: 22, y: 240, width: 20, height: 20 }], + })!; + expect(label.rotation).toBe(90); + expect(label.x).toBeCloseTo(22); + expect(Math.abs(label.y - 240)).toBeGreaterThanOrEqual(68); + expect( + edgeRouteLabel(route, { dims: { width: NaN, height: 20 } }) + ).toBeNull(); + }); +}); diff --git a/packages/web/src/lib/edgeRouteGeometry.ts b/packages/web/src/lib/edgeRouteGeometry.ts new file mode 100644 index 00000000..7146446e --- /dev/null +++ b/packages/web/src/lib/edgeRouteGeometry.ts @@ -0,0 +1,204 @@ +import type { Dims, ObstacleBox, Point } from './edgeLabelLayout'; + +export interface EdgeRouteSegment { + source: Point; + target: Point; + length: number; + start: number; + end: number; + angle: number; +} + +export interface EdgeRouteGeometry { + points: readonly Point[]; + segments: readonly EdgeRouteSegment[]; + length: number; + path: string; + target: Point & { angle: number }; +} + +const finitePoint = (point: Point) => + Number.isFinite(point.x) && Number.isFinite(point.y); +const clamp = (value: number, low: number, high: number) => + Math.max(low, Math.min(high, value)); +const fraction = (value: number) => + clamp(Number.isFinite(value) ? value : 0.5, 0, 1); + +export function createEdgeRoute( + input: readonly Point[] +): EdgeRouteGeometry | null { + if (input.some(point => !finitePoint(point))) return null; + const points: Point[] = []; + for (const point of input) { + const last = points.at(-1); + if (last?.x === point.x && last.y === point.y) continue; + const previous = points.at(-2); + if (previous && last) { + const ax = last.x - previous.x, + ay = last.y - previous.y; + const bx = point.x - last.x, + by = point.y - last.y; + if (ax * by === ay * bx && ax * bx + ay * by > 0) points.pop(); + } + points.push({ ...point }); + } + if (points.length < 2) return null; + const segments: EdgeRouteSegment[] = []; + let length = 0; + for (let i = 1; i < points.length; i++) { + const source = points[i - 1], + target = points[i]; + const dx = target.x - source.x, + dy = target.y - source.y; + const size = Math.hypot(dx, dy); + const end = length + size; + if (!Number.isFinite(end) || size === 0) return null; + segments.push({ + source, + target, + length: size, + start: length, + end, + angle: (Math.atan2(dy, dx) * 180) / Math.PI, + }); + length = end; + } + const last = segments[segments.length - 1]; + return { + points, + segments, + length, + path: points + .map((point, i) => `${i ? 'L' : 'M'}${point.x},${point.y}`) + .join(' '), + target: { ...last.target, angle: last.angle }, + }; +} + +export function pointOnEdgeRoute(route: EdgeRouteGeometry, value: number) { + const t = fraction(value), + distance = t * route.length; + const segment = + route.segments.find(part => distance < part.end) ?? + route.segments[route.segments.length - 1]; + const local = clamp((distance - segment.start) / segment.length, 0, 1); + return { + x: segment.source.x + (segment.target.x - segment.source.x) * local, + y: segment.source.y + (segment.target.y - segment.source.y) * local, + angle: segment.angle, + t, + }; +} + +export function projectEdgeRoutePointer( + route: EdgeRouteGeometry, + pointer: Point, + previousT = 0.5 +) { + const previous = fraction(previousT); + if (!finitePoint(pointer)) return previous; + let bestT = previous, + bestDistance = Infinity; + for (const segment of route.segments) { + const dx = segment.target.x - segment.source.x; + const dy = segment.target.y - segment.source.y; + const along = clamp( + ((pointer.x - segment.source.x) * (dx / segment.length) + + (pointer.y - segment.source.y) * (dy / segment.length)) / + segment.length, + 0, + 1 + ); + const x = segment.source.x + along * dx; + const y = segment.source.y + along * dy; + const distance = Math.hypot(pointer.x - x, pointer.y - y); + const t = (segment.start + along * segment.length) / route.length; + if ( + distance < bestDistance - 1e-9 || + (Math.abs(distance - bestDistance) <= 1e-9 && + Math.abs(t - previous) < Math.abs(bestT - previous)) + ) { + bestDistance = distance; + bestT = t; + } + } + return bestT; +} + +export function edgeRouteLabel( + route: EdgeRouteGeometry, + options: { + dims: Dims; + t?: number; + offset?: number; + padding?: number; + obstacles?: readonly ObstacleBox[]; + } +) { + const { dims, obstacles = [] } = options; + const offset = options.offset ?? 22, + padding = options.padding ?? 8; + if ( + ![dims.width, dims.height, offset, padding].every(Number.isFinite) || + dims.width <= 0 || + dims.height <= 0 || + padding < 0 + ) + return null; + const desired = fraction(options.t ?? 0.5) * route.length; + const candidates: Array<{ + x: number; + y: number; + t: number; + rotation: number; + iconRotation: number; + }> = []; + for (const segment of route.segments) { + const margin = dims.width / 2 + padding; + if (segment.length < 2 * margin) continue; + const low = segment.start + margin, + high = segment.end - margin; + const flipped = segment.angle > 90 || segment.angle < -90; + const rotation = flipped + ? segment.angle + (segment.angle > 90 ? -180 : 180) + : segment.angle; + const radians = (rotation * Math.PI) / 180; + const width = + Math.abs(Math.cos(radians)) * dims.width + + Math.abs(Math.sin(radians)) * dims.height; + const height = + Math.abs(Math.sin(radians)) * dims.width + + Math.abs(Math.cos(radians)) * dims.height; + for (const distance of new Set([ + clamp(desired, low, high), + (low + high) / 2, + low, + high, + ])) { + const point = pointOnEdgeRoute(route, distance / route.length); + const x = point.x + Math.sin(radians) * offset; + const y = point.y - Math.cos(radians) * offset; + if ( + obstacles.some( + box => + Math.abs(x - box.x) < (width + box.width) / 2 + padding && + Math.abs(y - box.y) < (height + box.height) / 2 + padding + ) + ) + continue; + candidates.push({ + x, + y, + t: distance / route.length, + rotation, + iconRotation: flipped ? 180 : 0, + }); + } + } + candidates.sort( + (a, b) => + Math.abs(a.t * route.length - desired) - + Math.abs(b.t * route.length - desired) || a.t - b.t + ); + return candidates[0] ?? null; +} From 1d2d9f8f7ecb41a31b670cd143cf9bf3ca9c628a Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 23:08:48 -0700 Subject: [PATCH 02/19] fix(graph): keep offset labels on their routed leg --- ...-28-offset-label-drag-needs-offset-path.md | 47 +++++++++++ .agents/issues/INDEX.md | 1 + docs/architecture/schematic-connections.md | 11 ++- .../lib/__tests__/edgeRouteGeometry.test.ts | 28 +++++++ packages/web/src/lib/edgeRouteGeometry.ts | 84 +++++++++++-------- 5 files changed, 136 insertions(+), 35 deletions(-) create mode 100644 .agents/inbox/2026-10-28-offset-label-drag-needs-offset-path.md diff --git a/.agents/inbox/2026-10-28-offset-label-drag-needs-offset-path.md b/.agents/inbox/2026-10-28-offset-label-drag-needs-offset-path.md new file mode 100644 index 00000000..e65076a1 --- /dev/null +++ b/.agents/inbox/2026-10-28-offset-label-drag-needs-offset-path.md @@ -0,0 +1,47 @@ +# Project dragged labels onto their displayed offset path + +2026-10-28 UTC, detailed item-wiring preparation for #220. Existing guidance +was searched through `.agents/issues/INDEX.md`, `edgeLabelLayout.ts` and the +schematic lessons. This is a controlled helper regression found before D3 +integration, not an observed failure in a deployed item router. + +The independent review of `eee4063c43e619a33c48fd0408bb9dc2dd45cc92` asked for +an offset-label drag case beside a close return wire. The helper initially +projected the pointer onto the wire centerline. On a U-shaped route whose +horizontal legs are 40 units apart, the lower leg's upright label is offset22 +units upward. Its center is18 units from the upper wire and22 from its own. +Nearest-wire projection therefore selects the wrong leg even with the prior +fraction available as a tie-breaker: the distances are not tied. + +Command, supported Node22.18, existing clean-installed partial probe, one worker: + +```sh +node node_modules/vitest/vitest.mjs run --root packages/web \ + src/lib/__tests__/edgeRouteGeometry.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Before correction: 9 passed, 1 failed, exit1, 0.713s. The executed new assertion +expected `340 / 440 = 0.772727...` but received `100 / 440 = 0.227272...`. +It passed a requested offset22 while the earlier helper ignored that extra +argument. The production API was then extended explicitly, not the assertion +relaxed. After correction: 10 passed, no skips, exit0, 0.691s (8ms test bodies). +Scoped strict ES2020 TypeScript checking of helper and test also passed. Source +and test were copied unchanged into the probe; this is not full-tree acceptance. + +Corrective rule: use the same upright perpendicular displacement for pointer +projection and label painting. At D3 drag start, retain where inside the label +the pointer grabbed it, then project the adjusted label center onto the offset +route. Preserve icon rotation around the icon's own center. Keep the direct +wire projection available for wire hit testing; it has different geometry. + +The review also found repeated route scans and candidate sorting in placement. +Interpolate each candidate directly on its current segment and retain the best +candidate in one pass. Placement now has O(segments × obstacles + segments) +work with at most four candidates per segment. Runtime callers still need +explicit bounds and a suitable update cadence. A null sampled placement means +no tested slot was clear, not that every mathematical position is obstructed. + +The regression lives in `edgeRouteGeometry.test.ts`. No browser/database/image +suite ran locally, no canonical positions changed, and no item-renderer +acceptance is claimed. Carry this finding with the D3 consumer fix and its +actual pointer/browser gates through normal review. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index 6238360f..f00d08c9 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,7 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| A dragged offset label jumps to a nearby return wire despite a prior-leg tie-breaker | [Project onto the displayed offset path](../inbox/2026-10-28-offset-label-drag-needs-offset-path.md); close-return-leg geometry negative and correction | | Extra graph-fixture batches can continue while timeout cleanup deletes their graph | [Keep additional edges inside tracked seeding](../inbox/2026-10-28-fixture-extras-must-share-cancellation.md); delayed edge-response owned-fixture regression | | A clean schematic metric passes while wires are invisible, or a new relationship button makes an old member locator ambiguous | [Require actual paint and distinct controls](../inbox/2026-10-28-schematic-gates-must-see-painted-wires.md); actual overview bounds/paint/direction checks and opacity-zero negative control | | Open bus lists disagree with live groups, failed routing searches multiply work, or hidden-link counts omit a partition | [Resolve live memberships and cap the whole invocation](../inbox/2026-10-28-bus-views-need-live-membership-and-total-budgets.md); relationship/live-update regressions, budget negative, CI text-bound failure | diff --git a/docs/architecture/schematic-connections.md b/docs/architecture/schematic-connections.md index 602785c4..e0ad2c6a 100644 --- a/docs/architecture/schematic-connections.md +++ b/docs/architecture/schematic-connections.md @@ -125,7 +125,8 @@ projection needed by the D3 path/hitbox/arrow/label consumers. Consecutive coincident points do not produce broken paths; a nonzero closed loop retains its travel and direction. Label placement fits an entire label on one segment, keeps text upright, retains original icon direction and returns no placement -when cards or previous labels leave no clear slot. Equal-distance pointer hits +when no sampled slot clears cards or previous labels. This is conservative +admission, not proof that every possible position is obstructed. Equal-distance pointer hits prefer the prior leg so dragging does not jump across a nearby return wire. This foundation is not yet connected to the item renderer and does not establish @@ -140,3 +141,11 @@ clean-installed partial probe: 9 passed, no skips, 0.69s (8ms test bodies). Cases cover elbows, reversed segments, closed loops, duplicate/invalid points, nearest-leg dragging, ties, long labels and rotated obstacle footprints. This is scoped helper evidence, not a full-tree browser/database/performance result. + +Independent foundation review also required offset-aware dragging beside close +return wires. The executed negative selected fraction100/440 instead of340/440; +matching projection to the displayed offset corrected it. All ten cases passed +serially in0.691s and scoped strict ES2020 types passed. Candidate interpolation +now stays on its current segment and keeps only the best placement, avoiding +repeated route scans/sorting. The [canonical lesson](../../.agents/inbox/2026-10-28-offset-label-drag-needs-offset-path.md) +records the negative, correction and runtime integration limits. diff --git a/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts index ff05a127..6e6f71f8 100644 --- a/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts +++ b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts @@ -119,6 +119,34 @@ describe('item wire geometry', () => { expect(projectEdgeRoutePointer(route, { x: NaN, y: 0 }, 0.8)).toBe(0.8); }); + it('drags an offset label along its rendered lane when the neighboring wire is closer', () => { + const route = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 40 }, + { x: 0, y: 40 }, + ])!; + const label = edgeRouteLabel(route, { + dims: { width: 60, height: 20 }, + t: 340 / 440, + })!; + expect(label).toMatchObject({ + x: 100, + y: 18, + rotation: 0, + iconRotation: 180, + }); + expect(projectEdgeRoutePointer(route, label, label.t)).toBeCloseTo( + 100 / 440 + ); + expect( + projectEdgeRoutePointer(route, label, label.t, { offset: 22 }) + ).toBeCloseTo(340 / 440); + expect( + projectEdgeRoutePointer(route, { x: 80, y: 18 }, label.t, { offset: 22 }) + ).toBeCloseTo(360 / 440); + }); + it('fits whole labels on a segment away from bends and retains original arrow orientation', () => { const route = createEdgeRoute([ { x: 200, y: 100 }, diff --git a/packages/web/src/lib/edgeRouteGeometry.ts b/packages/web/src/lib/edgeRouteGeometry.ts index 7146446e..02257596 100644 --- a/packages/web/src/lib/edgeRouteGeometry.ts +++ b/packages/web/src/lib/edgeRouteGeometry.ts @@ -17,12 +17,22 @@ export interface EdgeRouteGeometry { target: Point & { angle: number }; } +export interface EdgeRouteLabel { + x: number; + y: number; + t: number; + rotation: number; + iconRotation: number; +} + const finitePoint = (point: Point) => Number.isFinite(point.x) && Number.isFinite(point.y); const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value)); const fraction = (value: number) => clamp(Number.isFinite(value) ? value : 0.5, 0, 1); +const upright = (angle: number) => + angle > 90 ? angle - 180 : angle < -90 ? angle + 180 : angle; export function createEdgeRoute( input: readonly Point[] @@ -30,9 +40,9 @@ export function createEdgeRoute( if (input.some(point => !finitePoint(point))) return null; const points: Point[] = []; for (const point of input) { - const last = points.at(-1); + const last = points[points.length - 1]; if (last?.x === point.x && last.y === point.y) continue; - const previous = points.at(-2); + const previous = points[points.length - 2]; if (previous && last) { const ax = last.x - previous.x, ay = last.y - previous.y; @@ -93,24 +103,29 @@ export function pointOnEdgeRoute(route: EdgeRouteGeometry, value: number) { export function projectEdgeRoutePointer( route: EdgeRouteGeometry, pointer: Point, - previousT = 0.5 + previousT = 0.5, + options: { offset?: number } = {} ) { const previous = fraction(previousT); - if (!finitePoint(pointer)) return previous; + const offset = options.offset ?? 0; + if (!finitePoint(pointer) || !Number.isFinite(offset)) return previous; let bestT = previous, bestDistance = Infinity; for (const segment of route.segments) { const dx = segment.target.x - segment.source.x; const dy = segment.target.y - segment.source.y; + const angle = (upright(segment.angle) * Math.PI) / 180; + const sx = segment.source.x + Math.sin(angle) * offset; + const sy = segment.source.y - Math.cos(angle) * offset; const along = clamp( - ((pointer.x - segment.source.x) * (dx / segment.length) + - (pointer.y - segment.source.y) * (dy / segment.length)) / + ((pointer.x - sx) * (dx / segment.length) + + (pointer.y - sy) * (dy / segment.length)) / segment.length, 0, 1 ); - const x = segment.source.x + along * dx; - const y = segment.source.y + along * dy; + const x = sx + along * dx; + const y = sy + along * dy; const distance = Math.hypot(pointer.x - x, pointer.y - y); const t = (segment.start + along * segment.length) / route.length; if ( @@ -146,22 +161,15 @@ export function edgeRouteLabel( ) return null; const desired = fraction(options.t ?? 0.5) * route.length; - const candidates: Array<{ - x: number; - y: number; - t: number; - rotation: number; - iconRotation: number; - }> = []; + let best: EdgeRouteLabel | null = null; + let bestDistance = Infinity; for (const segment of route.segments) { const margin = dims.width / 2 + padding; if (segment.length < 2 * margin) continue; const low = segment.start + margin, high = segment.end - margin; const flipped = segment.angle > 90 || segment.angle < -90; - const rotation = flipped - ? segment.angle + (segment.angle > 90 ? -180 : 180) - : segment.angle; + const rotation = upright(segment.angle); const radians = (rotation * Math.PI) / 180; const width = Math.abs(Math.cos(radians)) * dims.width + @@ -175,9 +183,15 @@ export function edgeRouteLabel( low, high, ])) { - const point = pointOnEdgeRoute(route, distance / route.length); - const x = point.x + Math.sin(radians) * offset; - const y = point.y - Math.cos(radians) * offset; + const local = (distance - segment.start) / segment.length; + const x = + segment.source.x + + (segment.target.x - segment.source.x) * local + + Math.sin(radians) * offset; + const y = + segment.source.y + + (segment.target.y - segment.source.y) * local - + Math.cos(radians) * offset; if ( obstacles.some( box => @@ -186,19 +200,21 @@ export function edgeRouteLabel( ) ) continue; - candidates.push({ - x, - y, - t: distance / route.length, - rotation, - iconRotation: flipped ? 180 : 0, - }); + const delta = Math.abs(distance - desired); + if ( + delta < bestDistance || + (delta === bestDistance && distance / route.length < best!.t) + ) { + bestDistance = delta; + best = { + x, + y, + t: distance / route.length, + rotation, + iconRotation: flipped ? 180 : 0, + }; + } } } - candidates.sort( - (a, b) => - Math.abs(a.t * route.length - desired) - - Math.abs(b.t * route.length - desired) || a.t - b.t - ); - return candidates[0] ?? null; + return best; } From d9a0a5855612290b7c765999440b0d1d55b36333 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 23:17:37 -0700 Subject: [PATCH 03/19] refactor(graph): share item wire paths across render consumers --- docs/architecture/schematic-connections.md | 23 +- .../InteractiveGraphVisualization.tsx | 263 +++++++----------- .../interactions/core-matrix.spec.ts | 9 +- 3 files changed, 129 insertions(+), 166 deletions(-) diff --git a/docs/architecture/schematic-connections.md b/docs/architecture/schematic-connections.md index e0ad2c6a..dc3ba5af 100644 --- a/docs/architecture/schematic-connections.md +++ b/docs/architecture/schematic-connections.md @@ -129,10 +129,11 @@ when no sampled slot clears cards or previous labels. This is conservative admission, not proof that every possible position is obstructed. Equal-distance pointer hits prefer the prior leg so dragging does not jump across a nearby return wire. -This foundation is not yet connected to the item renderer and does not establish -routed item acceptance. Next, adapt all four geometry consumers together, then -add bounded routing/admission, named boundary connections, exact deferred counts -and accessible original relationships. Routing and obstacle searches belong to +The next committed increment connects this geometry to the D3 path, hitbox, +arrow and label consumers. It still supplies straight border-to-border paths; +this does not establish routed item acceptance. Next add bounded routing and +admission, named boundary connections, exact deferred counts and accessible +original relationships. Routing and obstacle searches belong to bounded updates after layout or scope changes, not every camera/simulation tick. Preserve edit/Grow, pointer hitboxes, label dragging and canonical positions. @@ -149,3 +150,17 @@ serially in0.691s and scoped strict ES2020 types passed. Candidate interpolation now stays on its current segment and keeps only the best placement, avoiding repeated route scans/sorting. The [canonical lesson](../../.agents/inbox/2026-10-28-offset-label-drag-needs-offset-path.md) records the negative, correction and runtime integration limits. + +The D3 adaptation caches label dimensions after initial text sizing, reuses the +path for paint and hit testing, places arrows from its final segment and slides +labels along its displayed offset. Culling composes with route/label visibility +instead of revealing deferred geometry. Label dragging updates only that label +against cached obstacles. The interaction diagnostic clicks the actual path +midpoint; the center of a bent path's bounding box need not lie on the path. + +Scoped web typecheck initially rejected a D3 nullable-style overload; the callback +form corrected it, and the same check passed in3.66s. Scoped lint returned +0 errors and298 warnings in the existing large renderer; no warning-free claim. +These checks used the partial probe and are not actual browser acceptance. +All original runtime/visual/durability gates and new item-wiring cases remain +required before this branch can be integrated or #220 closed. diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 3b92e8c2..9c64f325 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -49,7 +49,8 @@ import { RelationshipType, RELATIONSHIP_OPTIONS, getRelationshipConfig } from '. import { useAdaptiveQuality } from '../hooks/useAdaptiveQuality'; import { nodeLifeClasses, nodeGlowFilter, isActiveStatus, isCompletedStatus, edgeFlowClass } from '../lib/nodeAnimations'; import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerge'; -import { edgeLabelPlacement, clearSegment, slideTFromPointer, chooseLabelT } from '../lib/edgeLabelLayout'; +import type { ObstacleBox } from '../lib/edgeLabelLayout'; +import { createEdgeRoute, edgeRouteLabel, projectEdgeRoutePointer, type EdgeRouteGeometry, type EdgeRouteLabel } from '../lib/edgeRouteGeometry'; import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { separateCards } from '../lib/separateCards'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; @@ -2159,6 +2160,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const info = parallelInfo.get(e.id); e._pIndex = info ? info.index : 0; e._pTotal = info ? info.total : 1; + const label = getRelationshipConfig(e.type as RelationshipType)?.label || ''; + e._labelDims = { width: label.length * 7 + 34, height: 22 }; + e._labelIconCenter = { x: 0, y: 0 }; }); const liveScale = svg.node() ? d3.zoomTransform(svg.node() as Element).k : 1; // Arrow opacity routed through the edge LOD module: hidden direction → 0, @@ -2176,7 +2180,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .selectAll('.edge') .data(visibleEdges) .enter() - .append('line') + .append('path') + .attr('fill', 'none') + .attr('data-edge-id', (d: WorkItemEdge) => d.id) .attr('class', (d: WorkItemEdge) => { let classes = 'edge'; // After force-link binding, source/target are node objects at runtime @@ -2256,10 +2262,13 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .selectAll('.edge-clickable') .data(visibleEdges) .enter() - .append('line') + .append('path') .attr('class', 'edge-clickable') + .attr('fill', 'none') + .attr('data-edge-id', (d: WorkItemEdge) => d.id) .attr('stroke', 'transparent') .attr('stroke-width', 12) + .style('pointer-events', 'stroke') .style('cursor', 'pointer') .on('click', (event: MouseEvent, d: WorkItemEdge) => { event.stopPropagation(); @@ -3356,10 +3365,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const arrowElements = g.append('g') .attr('class', 'arrows-group') .selectAll('.arrow') - .data(validatedEdges) + .data(visibleEdges) .enter() .append('path') .attr('class', 'arrow') + .attr('data-edge-id', (d: WorkItemEdge) => d.id) .attr('d', 'M-16,-8 L0,0 L-16,8 L-8,0 Z') .attr('fill', (d: WorkItemEdge) => { // Use the relationship color for consistency with edge stroke @@ -3385,6 +3395,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .enter() .append('g') .attr('class', 'edge-label-group') + .attr('data-edge-id', (d: WorkItemEdge) => d.id) .style('opacity', (d: any) => labelVisibility(liveScale, d._pTotal || 1)) .style('pointer-events', (d: any) => labelVisibility(liveScale, d._pTotal || 1) > 0 ? 'all' : 'none') .style('cursor', 'pointer') @@ -3491,6 +3502,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Size and position elements after text is rendered setTimeout(() => { + if (simulationRef.current !== simulation) return; edgeLabelGroups.each(function(d: WorkItemEdge) { const group = d3.select(this); const textElement = group.select('.edge-label').node() as SVGTextElement; @@ -3503,6 +3515,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const paddingRight = 8; const totalWidth = paddingLeft + iconWidth + spacing + bbox.width + paddingRight; const totalHeight = Math.max(22, bbox.height + 8); + (d as any)._labelDims = { width: totalWidth, height: totalHeight }; + (d as any)._labelIconCenter = { x: -totalWidth / 2 + paddingLeft + 7, y: 0 }; // Update rectangle size and center it group.select('.edge-label-bg') @@ -3524,6 +3538,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('dominant-baseline', 'middle'); } }); + updateEdgePositions(true); }, 150); // Node click handling with edge creation @@ -3819,174 +3834,104 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }); let labelAvoidCounter = 0; - const updateEdgePositions = (forceAvoid = false) => { - // Dot mode (extreme zoom-out): all edges/arrows/labels are hidden, so there - // is nothing to position — skip the whole 1400-edge per-tick pass. - if (dotModeRef.current && !forceAvoid) { + let nodeLabelObstacles: ObstacleBox[] = []; + const labelRects = new Map(); + const applyLabel = (element: SVGGElement, d: any, placement: EdgeRouteLabel | null) => { + d._routeLabel = placement; + const label = d3.select(element); + label.style('display', () => placement ? edgeDisplay(d) : 'none'); + if (!placement) { + labelRects.delete(d.id); return; } - // Live zoom scale drives the LOD-aware parallel-edge fan-out (collapses to - // the centre line when zoomed out) and arrow sizing. + d.labelT = placement.t; + label.attr('transform', `translate(${placement.x},${placement.y}) rotate(${placement.rotation})`); + const center = d._labelIconCenter; + label.select('.edge-label-icon').attr('transform', placement.iconRotation + ? `rotate(${placement.iconRotation}, ${center.x}, ${center.y})` : null); + const angle = placement.rotation * Math.PI / 180; + labelRects.set(d.id, { + x: placement.x, y: placement.y, + width: Math.abs(Math.cos(angle)) * d._labelDims.width + Math.abs(Math.sin(angle)) * d._labelDims.height, + height: Math.abs(Math.sin(angle)) * d._labelDims.width + Math.abs(Math.cos(angle)) * d._labelDims.height + }); + }; + const updateEdgePositions = (forceAvoid = false) => { + if (dotModeRef.current && !forceAvoid) return; const liveK = svg.node() ? d3.zoomTransform(svg.node() as Element).k : 1; - - // Border-to-border anchors: the edge starts/ends where the center line - // crosses each card's border, not at the buried center. Computed once per - // edge per tick (shared datum) so line, hitbox and arrow agree. The anchor - // slides around the border as the nodes move — shortest border path. For - // parallel edges, shift the whole anchored line perpendicular to itself by - // perpendicularOffset so siblings don't overlap. - linkElements.each(function (d: any) { + linkElements.each(function(d: any) { const ep = edgeBorderEndpoints( { x: d.source.x || 0, y: d.source.y || 0 }, getNodeDimensions(d.source), { x: d.target.x || 0, y: d.target.y || 0 }, getNodeDimensions(d.target) ); if ((d._pTotal || 1) > 1) { const off = perpendicularOffset(d._pIndex || 0, d._pTotal, liveK); - if (off !== 0) { - const lx = ep.x2 - ep.x1; - const ly = ep.y2 - ep.y1; - const len = Math.hypot(lx, ly) || 1; - const px = -ly / len; - const py = lx / len; - ep.x1 += px * off; ep.y1 += py * off; - ep.x2 += px * off; ep.y2 += py * off; - } + const dx = ep.x2 - ep.x1, dy = ep.y2 - ep.y1; + const length = Math.hypot(dx, dy) || 1; + ep.x1 -= dy / length * off; ep.y1 += dx / length * off; + ep.x2 -= dy / length * off; ep.y2 += dx / length * off; } - d._ep = ep; + d._route = createEdgeRoute([{ x: ep.x1, y: ep.y1 }, { x: ep.x2, y: ep.y2 }]); + }); + linkElements.attr('d', (d: any) => d._route?.path ?? null); + clickableEdges.attr('d', (d: any) => d._route?.path ?? null); + applyEdgeDisplays(); + if (simplifiedRef.current && !forceAvoid) return; + arrowElements.attr('transform', (d: any) => { + const route = d._route as EdgeRouteGeometry | null; + if (!route) return null; + const target = route.target; + const size = arrowVisibility(liveK, d._pTotal || 1).scale; + return `translate(${target.x},${target.y}) rotate(${target.angle}) scale(${size})`; }); - - // Update visible edge positions - linkElements - .attr('x1', (d: any) => d._ep.x1) - .attr('y1', (d: any) => d._ep.y1) - .attr('x2', (d: any) => d._ep.x2) - .attr('y2', (d: any) => d._ep.y2); - - // Update clickable edge positions - clickableEdges - .attr('x1', (d: any) => d._ep.x1) - .attr('y1', (d: any) => d._ep.y1) - .attr('x2', (d: any) => d._ep.x2) - .attr('y2', (d: any) => d._ep.y2); - - // Simplified (dense + zoomed out): arrows and edge labels are hidden - // (data-simplify CSS), so skip their per-tick positioning entirely — at - // 1400 edges that arrow transform + label placement pass is the bulk of - // the remaining per-tick cost in the whole-graph view. forceAvoid (the - // one-shot settle pass) still runs so labels are correct when you zoom in. - if (simplifiedRef.current && !forceAvoid) { - return; - } - - // Arrow sits at the TARGET border, pointing into the node. Parallel edges - // get a smaller arrow head (arrowVisibility.scale) so crowded siblings stay - // legible; lone edges keep the default size. - arrowElements - .attr('transform', (d: any) => { - const ep = d._ep; - const angle = Math.atan2(ep.y2 - ep.y1, ep.x2 - ep.x1) * 180 / Math.PI; - const size = arrowVisibility(liveK, d._pTotal || 1).scale; - return `translate(${ep.x2},${ep.y2}) rotate(${angle}) scale(${size})`; - }); - - // Edge labels: auto-centered in the clear span between the two node - // cards, pushed off ALL node cards once the simulation settles, and - // slidable by the user (d.labelT persists because the data merge keeps - // edge object identity stable; d.labelTUser pins a manual slide). labelAvoidCounter++; - // forceAvoid lets a one-shot caller (layout settle / pinned graphs that - // don't tick) run a full label de-overlap pass on demand. const runAvoidance = forceAvoid || (simulation.alpha() < 0.1 && labelAvoidCounter % 15 === 0); - const obstacles = runAvoidance - ? (simulation.nodes() as any[]).map((n: any) => { - const dims = getNodeDimensions(n); - return { x: n.x || 0, y: n.y || 0, width: dims.width, height: dims.height }; - }) - : null; - - // Labels placed earlier in this pass become obstacles for later ones, - // so labels avoid each other as well as every node card. - const placedLabels: Array<{ x: number; y: number; width: number; height: number }> = []; - edgeLabelGroups - .attr('transform', function(d: any) { - const source = { x: d.source.x || 0, y: d.source.y || 0 }; - const target = { x: d.target.x || 0, y: d.target.y || 0 }; - const sourceDims = getNodeDimensions(d.source); - const targetDims = getNodeDimensions(d.target); - - let labelW = 60; - let labelH = 20; - if (obstacles) { - const bbox = (this as SVGGElement).getBBox(); - labelW = bbox.width || 60; - labelH = bbox.height || 20; - if (!d.labelTUser) { - d.labelT = chooseLabelT({ - source, target, sourceDims, targetDims, - obstacles: obstacles.concat(placedLabels), - labelDims: { width: labelW, height: labelH } - }); - } - } - - const placement = edgeLabelPlacement({ source, target, sourceDims, targetDims, t: d.labelT }); - // Fan parallel-edge labels off the centre line to match their shifted - // paths (same perpendicular offset as the line above). - let labelX = placement.x; - let labelY = placement.y; - if ((d._pTotal || 1) > 1) { - const off = perpendicularOffset(d._pIndex || 0, d._pTotal, liveK); - if (off !== 0) { - const lx = target.x - source.x; - const ly = target.y - source.y; - const len = Math.hypot(lx, ly) || 1; - labelX += (-ly / len) * off; - labelY += (lx / len) * off; - } - } - if (obstacles) { - placedLabels.push({ x: labelX, y: labelY, width: labelW + 8, height: labelH + 8 }); - } - // edgeLabelPlacement keeps text upright by stripping 180° when the edge - // points "backward" — which leaves the directional label icon pointing - // the wrong way after a flip. Carry that lost 180° on the icon so its - // arrow tracks the real edge direction (and flips when the edge flips). - // CRITICAL: rotate around the ICON's own centre — a bare rotate(180) - // pivots on the group origin (0,0), which threw the icon across onto the - // label text (overlap) and left it upside-down. The icon is a 14×14 - // foreignObject at (x,y), so its centre is (x+7, y+7). - const trueAngle = (Math.atan2(target.y - source.y, target.x - source.x) * 180) / Math.PI; - const iconFlipped = trueAngle > 90 || trueAngle < -90; - const iconSel = d3.select(this).select('.edge-label-icon'); - if (iconFlipped) { - const ix = parseFloat(iconSel.attr('x') || '-7'); - const iy = parseFloat(iconSel.attr('y') || '-7'); - iconSel.attr('transform', `rotate(180, ${ix + 7}, ${iy + 7})`); - } else { - iconSel.attr('transform', null); - } - return `translate(${labelX},${labelY}) rotate(${placement.rotation})`; - }); + if (runAvoidance) nodeLabelObstacles = (simulation.nodes() as any[]).map(n => ({ + x: n.x || 0, y: n.y || 0, ...getNodeDimensions(n) + })); + const placed: ObstacleBox[] = []; + edgeLabelGroups.each(function(d: any) { + const route = d._route as EdgeRouteGeometry | null; + const placement = route && (runAvoidance || !d._labelBlocked) + ? edgeRouteLabel(route, { + dims: d._labelDims, t: d.labelT, + obstacles: runAvoidance ? nodeLabelObstacles.concat(placed) : [] + }) : null; + if (runAvoidance) d._labelBlocked = !placement; + applyLabel(this, d, placement); + const box = labelRects.get(d.id); + if (box) placed.push(box); + }); }; - // Let users slide a label along its edge, within the clear span. edgeLabelGroups .style('cursor', 'grab') .call(d3.drag() - // Locked (phone) → reject so the gesture pans instead of sliding the label. .filter((event: any) => !interactionLockedRef.current && !event.ctrlKey && !event.button) - .on('start', (event) => { + .on('start', (event, d: any) => { event.sourceEvent?.stopPropagation(); + const [x, y] = d3.pointer(event, g.node() as any); + d._labelGrab = d._routeLabel + ? { x: x - d._routeLabel.x, y: y - d._routeLabel.y } + : { x: 0, y: 0 }; }) .on('drag', function(event, d: any) { - const [px, py] = d3.pointer(event, g.node() as any); - const source = { x: d.source.x || 0, y: d.source.y || 0 }; - const target = { x: d.target.x || 0, y: d.target.y || 0 }; - const segment = clearSegment(source, target, getNodeDimensions(d.source), getNodeDimensions(d.target)); - d.labelT = slideTFromPointer({ x: px, y: py }, source, target, segment); - d.labelTUser = true; - updateEdgePositions(); - })); + if (!d._route || !d._labelGrab) return; + const [x, y] = d3.pointer(event, g.node() as any); + const t = projectEdgeRoutePointer(d._route, { + x: x - d._labelGrab.x, y: y - d._labelGrab.y + }, d.labelT, { offset: 22 }); + const placement = edgeRouteLabel(d._route, { + dims: d._labelDims, t, + obstacles: nodeLabelObstacles.concat([...labelRects].filter(([id]) => id !== d.id).map(([, box]) => box)) + }); + if (placement) { + d.labelTUser = true; + d._labelBlocked = false; + applyLabel(this, d, placement); + } + }) + .on('end', (_event, d: any) => { delete d._labelGrab; })); // Simulation tick. Order matters: nodes move first, then every line, // arrow and label re-anchors to the nodes' new positions — edges can @@ -4041,13 +3986,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const CULL_MIN_SCALE = 0.5; let cullCounter = 0; let cullActive = false; + const edgeDisplay = (d: any) => !d._route || (cullActive && d.source?._culled && d.target?._culled) ? 'none' : null; + const applyEdgeDisplays = () => { + linkElements.style('display', edgeDisplay); + clickableEdges.style('display', edgeDisplay); + arrowElements.style('display', edgeDisplay); + edgeLabelGroups.style('display', (d: any) => d._routeLabel ? edgeDisplay(d) : 'none'); + }; const clearCull = () => { nodeElements.style('display', null); - linkElements.style('display', null); - clickableEdges.style('display', null); - arrowElements.style('display', null); - edgeLabelGroups.style('display', null); cullActive = false; + applyEdgeDisplays(); }; const applyViewportCull = () => { const svgEl = svg.node(); @@ -4069,11 +4018,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i d._culled = !visible; return visible ? null : 'none'; }); - const edgeDisplay = (d: any) => (d.source?._culled && d.target?._culled ? 'none' : null); - linkElements.style('display', edgeDisplay); - clickableEdges.style('display', edgeDisplay); - arrowElements.style('display', edgeDisplay); - edgeLabelGroups.style('display', edgeDisplay); + applyEdgeDisplays(); }; simulation.on('tick', () => { diff --git a/tests/diagnostics/interactions/core-matrix.spec.ts b/tests/diagnostics/interactions/core-matrix.spec.ts index 47de5a8a..a84cb8f7 100644 --- a/tests/diagnostics/interactions/core-matrix.spec.ts +++ b/tests/diagnostics/interactions/core-matrix.spec.ts @@ -118,10 +118,13 @@ test.describe('core interaction matrix @geometry', () => { // 6. Click an edge → relationship editor opens await page.waitForTimeout(500); const edgeBox = await page.evaluate(() => { - const e = document.querySelector('.graph-container svg .edge-clickable, .graph-container svg .edge') as SVGLineElement | null; + const e = document.querySelector('.graph-container svg .edge-clickable, .graph-container svg .edge') as SVGGeometryElement | null; if (!e) return null; - const r = e.getBoundingClientRect(); - return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + const matrix = e.getScreenCTM(); + if (!matrix || e.getTotalLength() === 0) return null; + const point = e.getPointAtLength(e.getTotalLength() / 2); + const screen = new DOMPoint(point.x, point.y).matrixTransform(matrix); + return { x: screen.x, y: screen.y }; }); if (edgeBox) { await page.mouse.click(edgeBox.x, edgeBox.y); From 9931b3ce70c4c0a49d596c9aa1f32398216abf93 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 23:45:56 -0700 Subject: [PATCH 04/19] fix(graph): preserve hidden label sizing and opposite wire lanes --- ...28-hidden-labels-need-valid-measurement.md | 52 +++ .agents/issues/INDEX.md | 1 + .github/workflows/ci.yml | 5 + docs/architecture/schematic-connections.md | 19 ++ .../InteractiveGraphVisualization.tsx | 79 ++--- .../components/RelationshipEditorWindow.tsx | 4 + .../lib/__tests__/edgeRouteGeometry.test.ts | 17 + packages/web/src/lib/edgeRouteGeometry.ts | 19 ++ tests/diagnostics/layout/geometry.spec.ts | 16 +- tests/e2e/graph/item-wire-consumers.spec.ts | 300 ++++++++++++++++++ tests/lib/runner/unified-cli.test.mjs | 6 +- tests/sequences/unified.config.mjs | 7 +- 12 files changed, 458 insertions(+), 67 deletions(-) create mode 100644 .agents/inbox/2026-10-28-hidden-labels-need-valid-measurement.md create mode 100644 tests/e2e/graph/item-wire-consumers.spec.ts diff --git a/.agents/inbox/2026-10-28-hidden-labels-need-valid-measurement.md b/.agents/inbox/2026-10-28-hidden-labels-need-valid-measurement.md new file mode 100644 index 00000000..3353f71d --- /dev/null +++ b/.agents/inbox/2026-10-28-hidden-labels-need-valid-measurement.md @@ -0,0 +1,52 @@ +# Keep conservative label geometry when hidden SVG measurement is empty + +2026-10-28 UTC, independent review of item-wire source +`d9a0a5855612290b7c765999440b0d1d55b36333` for #220 / PR #221. Existing +guidance was searched in the symptom index and the offset-label lesson. This +was a source-review finding, not a reproduced deployed/browser incident. + +The first placement pass can hide an edge-label group before its delayed +150ms sizing callback runs. Accepting an empty `getBBox()` then replaces a +conservative text estimate with padding alone (36 units), allowing a long +label to be admitted using an undersized obstacle/background. Initialize text, +icon and background from one conservative cache, and replace that cache only +when both measured dimensions are positive. Hidden labels must not become +small merely because they are not currently measurable. + +The same review found a line-only diagnostic reading `x1.baseVal` after the +renderer changed to SVG paths. Read actual path endpoints through +`getPointAtLength` and transform them through `getScreenCTM`. Searching for +remaining `SVGLineElement` / `x1.baseVal` consumers found this one diagnostic. + +A separate source audit found that directed perpendicular offsets put reverse +edges on the same lane: reversing both the direction and lane sign cancels the +separation. Use a canonical unordered endpoint direction to define lane sides, +while preserving the original route direction for arrows and editing. + +Dated local commands used supported Node22.18, nice10, one worker, in the +existing clean-installed partial probe (not full-tree acceptance): + +```sh +node node_modules/vitest/vitest.mjs run --root packages/web \ + src/lib/__tests__/edgeRouteGeometry.test.ts --maxWorkers=1 --no-file-parallelism +node node_modules/typescript/bin/tsc --noEmit -p packages/web/tsconfig.json +node node_modules/@playwright/test/cli.js test tests/e2e/graph/item-wire-consumers.spec.ts --list +node --test --test-concurrency=1 tests/lib/runner/unified-cli.test.mjs +``` + +Results: 11 geometry cases passed in0.722s (9ms bodies); scoped web types passed +in3.72s; two browser cases discovered, **not executed locally**; 32 CLI harness +cases passed in4.56s without a browser. Scoped flat-config lint over both +components/helper/test passed with0 errors and314 warnings. An initial lint +invocation from the uninstalled worktree failed to resolve a legacy config; +the corrected command ran from the probe's web package with +`ESLINT_USE_FLAT_CONFIG=true`. That setup error is not a source lint result. + +The new blocking `item-wires` sequence is enrolled in PR/full profiles and CI +after smoke, before longer suites, with one browser worker and zero retries. +It exercises actual SVG paint/hitboxes/arrows, pointer dragging, wire/label +editing and initially hidden label sizing, retains PNG/JSON, rejects writes, +compares original graph data and uses owned fixture cleanup. CI execution and +artifact review remain required. The route is still straight; these changes +do not establish bounded obstacle routing, visible detail budgets, complete +spaghetti detection or closure of #220. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index f00d08c9..79e829a6 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,7 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| Hidden item labels acquire tiny backgrounds, reverse wires share a lane, or line-only diagnostics fail after path conversion | [Preserve conservative dimensions and shared path consumers](../inbox/2026-10-28-hidden-labels-need-valid-measurement.md); item-wire browser sequence and opposite-direction lane regression | | A dragged offset label jumps to a nearby return wire despite a prior-leg tie-breaker | [Project onto the displayed offset path](../inbox/2026-10-28-offset-label-drag-needs-offset-path.md); close-return-leg geometry negative and correction | | Extra graph-fixture batches can continue while timeout cleanup deletes their graph | [Keep additional edges inside tracked seeding](../inbox/2026-10-28-fixture-extras-must-share-cancellation.md); delayed edge-response owned-fixture regression | | A clean schematic metric passes while wires are invisible, or a new relationship button makes an old member locator ambiguous | [Require actual paint and distinct controls](../inbox/2026-10-28-schematic-gates-must-see-painted-wires.md); actual overview bounds/paint/direction checks and opacity-zero negative control | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee4ff567..f92f6d60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,6 +197,11 @@ jobs: env: TEST_URL: http://localhost:3127 CI: true + - name: Item wire consumer acceptance + run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence item-wires --out test-artifacts/ci-item-wires + env: + TEST_URL: http://localhost:3127 + CI: true - name: New-user quality gate (headed under virtual display) run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence new-user --out test-artifacts/ci-new-user env: diff --git a/docs/architecture/schematic-connections.md b/docs/architecture/schematic-connections.md index dc3ba5af..f46bf9bb 100644 --- a/docs/architecture/schematic-connections.md +++ b/docs/architecture/schematic-connections.md @@ -164,3 +164,22 @@ form corrected it, and the same check passed in3.66s. Scoped lint returned These checks used the partial probe and are not actual browser acceptance. All original runtime/visual/durability gates and new item-wiring cases remain required before this branch can be integrated or #220 closed. + +Review of the first consumer commit (`d9a0a58`) found empty measurement of +initially hidden labels and a line-only geometry diagnostic. The correction +initializes all label parts from conservative dimensions and accepts only +positive text measurements; diagnostics now read actual SVG path endpoints. +Opposite-direction parallel edges share a canonical lane normal so they stay +separate while their arrows retain direction. Wire and label clicks open the +same original relationship editor, whose controls now have accessible names. + +Two real-browser cases are registered as blocking `item-wires` acceptance in +CI immediately after smoke (one worker, zero retries), retaining actual +paint/hitbox/arrow geometry and screenshots, pointer drag/edit behavior, hidden +label dimensions and unchanged graph data. Local discovery found both cases; +they have not run locally. The partial probe passed11 geometry cases in0.722s, +32 CLI harness cases in4.56s and scoped types; scoped lint reports0 errors, +314 warnings across the two components/helper/test. The +[review lesson](../../.agents/inbox/2026-10-28-hidden-labels-need-valid-measurement.md) +records the evidence and limits. These are straight item paths preparing the +bounded routing increment, not completion of #220. diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 9c64f325..725f4f53 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -50,7 +50,7 @@ import { useAdaptiveQuality } from '../hooks/useAdaptiveQuality'; import { nodeLifeClasses, nodeGlowFilter, isActiveStatus, isCompletedStatus, edgeFlowClass } from '../lib/nodeAnimations'; import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerge'; import type { ObstacleBox } from '../lib/edgeLabelLayout'; -import { createEdgeRoute, edgeRouteLabel, projectEdgeRoutePointer, type EdgeRouteGeometry, type EdgeRouteLabel } from '../lib/edgeRouteGeometry'; +import { createParallelEdgeRoute, edgeRouteLabel, projectEdgeRoutePointer, type EdgeRouteGeometry, type EdgeRouteLabel } from '../lib/edgeRouteGeometry'; import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { separateCards } from '../lib/separateCards'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; @@ -2161,8 +2161,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i e._pIndex = info ? info.index : 0; e._pTotal = info ? info.total : 1; const label = getRelationshipConfig(e.type as RelationshipType)?.label || ''; - e._labelDims = { width: label.length * 7 + 34, height: 22 }; - e._labelIconCenter = { x: 0, y: 0 }; + e._labelDims = { width: label.length * 7 + 36, height: 22 }; + e._labelIconCenter = { x: -e._labelDims.width / 2 + 13, y: 0 }; }); const liveScale = svg.node() ? d3.zoomTransform(svg.node() as Element).k : 1; // Arrow opacity routed through the edge LOD module: hidden direction → 0, @@ -2172,6 +2172,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Debug: Log edge visibility + const openEdgeEditor = (event: MouseEvent, edge: WorkItemEdge) => { + event.stopPropagation(); + event.preventDefault(); + setSelectedEdge(edge); + setShowRelationshipWindow(true); + setEditingEdge({ edge, position: { x: event.clientX, y: event.clientY } }); + setDragOffset({ x: 0, y: 0 }); + }; + // Create edges FIRST (so they render under nodes) const edgesGroup = g.append('g').attr('class', 'edges-group'); @@ -2270,31 +2279,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('stroke-width', 12) .style('pointer-events', 'stroke') .style('cursor', 'pointer') - .on('click', (event: MouseEvent, d: WorkItemEdge) => { - event.stopPropagation(); - - // Position dialog to the side of the edge to avoid obstructing the glow - const offset = 100; // Distance from edge - const dialogWidth = 300; // Estimated dialog width - const dialogHeight = 200; // Estimated dialog height - - // Calculate position to the right side, but check boundaries - let x = event.clientX + offset; - let y = event.clientY - dialogHeight / 2; - - // If dialog would go off right edge, position to the left - if (x + dialogWidth > window.innerWidth - 20) { - x = event.clientX - offset - dialogWidth; - } - - // Keep dialog within vertical bounds - if (y < 20) y = 20; - if (y + dialogHeight > window.innerHeight - 20) { - y = window.innerHeight - dialogHeight - 20; - } - - setSelectedEdge(d); - }) + .on('click', openEdgeEditor) .on('mouseover', function(event: MouseEvent, d: WorkItemEdge) { // Highlight the corresponding visible edge linkElements @@ -3399,19 +3384,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .style('opacity', (d: any) => labelVisibility(liveScale, d._pTotal || 1)) .style('pointer-events', (d: any) => labelVisibility(liveScale, d._pTotal || 1) > 0 ? 'all' : 'none') .style('cursor', 'pointer') - .on('click', function(event: MouseEvent, d: WorkItemEdge) { - event.stopPropagation(); - event.preventDefault(); // Prevent any default behavior - - // Open the editor, remembering WHERE the edge was clicked so the - // window can position itself away from the edge it edits - setShowRelationshipWindow(true); - setEditingEdge({ - edge: d, - position: { x: event.clientX, y: event.clientY } - }); - setDragOffset({ x: 0, y: 0 }); // Reset drag offset - }); + .on('click', openEdgeEditor); // Nodes always stack ABOVE edge labels/arrows — labels were stealing // clicks from node controls (the + icon) when they drifted over a card. @@ -3421,8 +3394,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i edgeLabelGroups .append('text') .attr('class', 'edge-label') - .attr('x', 0) - .attr('text-anchor', 'middle') + .attr('x', (d: any) => -d._labelDims.width / 2 + 28) + .attr('text-anchor', 'start') .attr('dominant-baseline', 'middle') .style('font-size', '10px') .style('font-weight', '600') @@ -3441,7 +3414,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('class', 'edge-label-icon') .attr('width', 14) .attr('height', 14) - .attr('x', -7) // Will be adjusted after measuring text + .attr('x', (d: any) => -d._labelDims.width / 2 + 6) .attr('y', -7) .style('pointer-events', 'none') .each(function(d: WorkItemEdge) { @@ -3473,6 +3446,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i edgeLabelGroups .insert('rect', ':first-child') // Insert before other elements .attr('class', 'edge-label-bg') + .attr('width', (d: any) => d._labelDims.width) + .attr('height', (d: any) => d._labelDims.height) + .attr('x', (d: any) => -d._labelDims.width / 2) + .attr('y', (d: any) => -d._labelDims.height / 2) .attr('rx', 8) .attr('ry', 8) .style('fill', (d: WorkItemEdge) => { @@ -3509,6 +3486,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (textElement) { const bbox = textElement.getBBox(); + if (!(bbox.width > 0 && bbox.height > 0)) return; const iconWidth = 14; const spacing = 8; const paddingLeft = 6; @@ -3864,14 +3842,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i { x: d.source.x || 0, y: d.source.y || 0 }, getNodeDimensions(d.source), { x: d.target.x || 0, y: d.target.y || 0 }, getNodeDimensions(d.target) ); - if ((d._pTotal || 1) > 1) { - const off = perpendicularOffset(d._pIndex || 0, d._pTotal, liveK); - const dx = ep.x2 - ep.x1, dy = ep.y2 - ep.y1; - const length = Math.hypot(dx, dy) || 1; - ep.x1 -= dy / length * off; ep.y1 += dx / length * off; - ep.x2 -= dy / length * off; ep.y2 += dx / length * off; - } - d._route = createEdgeRoute([{ x: ep.x1, y: ep.y1 }, { x: ep.x2, y: ep.y2 }]); + const offset = perpendicularOffset(d._pIndex || 0, d._pTotal || 1, liveK); + d._route = createParallelEdgeRoute( + { id: d.source.id, x: ep.x1, y: ep.y1 }, + { id: d.target.id, x: ep.x2, y: ep.y2 }, offset + ); }); linkElements.attr('d', (d: any) => d._route?.path ?? null); clickableEdges.attr('d', (d: any) => d._route?.path ?? null); diff --git a/packages/web/src/components/RelationshipEditorWindow.tsx b/packages/web/src/components/RelationshipEditorWindow.tsx index 5121836d..7dfe8334 100644 --- a/packages/web/src/components/RelationshipEditorWindow.tsx +++ b/packages/web/src/components/RelationshipEditorWindow.tsx @@ -289,6 +289,8 @@ export const RelationshipEditorWindow: React.FC = return createPortal(
=
} + {itemRelationshipScope && !semanticOverviewActive && itemRelationshipIndex.hierarchy.paths.get(id)?.map(key => itemRelationshipIndex.hierarchy.groups.get(key)?.label).filter(Boolean).join(' / ') || 'Ungrouped'} + onScope={setItemRelationshipScope} + onInspect={id => { setItemRelationshipScope(null); inspectOverviewItem(id, true); }} + onBack={() => setItemRelationshipScope(null)} + backLabel="Back to graph" + visible={true} + onEdit={id => { + const edge = workItemEdges.find(edge => edge.id === id); + if (!edge) return; + setItemRelationshipScope(null); + setSelectedEdge(edge); + setShowRelationshipWindow(true); + setEditingEdge({ edge, position: { x: window.innerWidth / 2, y: window.innerHeight / 2 } }); + setDragOffset({ x: 0, y: 0 }); + }} + />} void; focusId?: string; visible: boolean; + backLabel?: string; + onEdit?: (id: string) => void; } export function SemanticRelationshipBrowser({ @@ -28,6 +30,8 @@ export function SemanticRelationshipBrowser({ onBack, focusId, visible, + backLabel = 'Back to groups', + onEdit, }: Props) { const [type, setType] = useState(''); const [search, setSearch] = useState(''); @@ -164,7 +168,7 @@ export function SemanticRelationshipBrowser({ onClick={onBack} className="min-h-11 text-sm text-emerald-200" > - Back to groups + {backLabel}

{title}

{missingGroup && ( @@ -241,6 +245,7 @@ export function SemanticRelationshipBrowser({ className="rounded border border-gray-700 bg-gray-900 p-3" >

{edge.id}

+ {onEdit && } {endpoint(relationshipEndpoint(edge.source))}

→ {edge.type}

{endpoint(relationshipEndpoint(edge.target))} diff --git a/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts index f1fbb43c..5bd221f3 100644 --- a/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts +++ b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts @@ -3,6 +3,8 @@ import { createEdgeRoute, createParallelEdgeRoute, edgeRouteLabel, + edgeRouteArrowBounds, + edgeSegmentHitsBox, pointOnEdgeRoute, projectEdgeRoutePointer, } from '../edgeRouteGeometry'; @@ -219,4 +221,75 @@ describe('item wire geometry', () => { edgeRouteLabel(route, { dims: { width: NaN, height: 20 } }) ).toBeNull(); }); + it('places opposite-direction labels on clear sides instead of covering the neighboring wire', () => { + const upper = createEdgeRoute([ + { x: 0, y: -7 }, + { x: 400, y: -7 }, + ])!; + const lower = createEdgeRoute([ + { x: 400, y: 7 }, + { x: 0, y: 7 }, + ])!; + const segments = [...upper.segments, ...lower.segments]; + const dims = { width: 110, height: 26 }; + const first = edgeRouteLabel(upper, { + dims, + offset: 25, + offsets: [25, -25], + segments, + })!; + const second = edgeRouteLabel(lower, { + dims, + offset: 25, + offsets: [25, -25], + segments, + })!; + expect(first.y).toBe(-32); + expect(second.y).toBe(32); + expect(second.offset).toBe(-25); + for (const label of [first, second]) + expect( + segments.some(segment => + edgeSegmentHitsBox(segment, { ...label, ...dims }, 8) + ) + ).toBe(false); + }); + + it('reserves arrowheads and rejects diagonal wires through a label footprint', () => { + const route = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 0, y: 300 }, + ])!; + const arrow = edgeRouteArrowBounds(route); + expect(arrow.x).toBeCloseTo(0); + expect(arrow).toMatchObject({ + y: 292, + width: 16, + height: 16, + }); + const box = { x: 50, y: 50, width: 20, height: 20 }; + expect( + edgeSegmentHitsBox( + { source: { x: 0, y: 0 }, target: { x: 100, y: 100 } }, + box + ) + ).toBe(true); + expect( + edgeSegmentHitsBox( + { source: { x: 0, y: 0 }, target: { x: 20, y: 100 } }, + box + ) + ).toBe(false); + const horizontal = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 300, y: 0 }, + ])!; + const label = edgeRouteLabel(horizontal, { + dims: { width: 70, height: 26 }, + t: 1, + offset: 25, + obstacles: [edgeRouteArrowBounds(horizontal)], + })!; + expect(label.x + 35).toBeLessThanOrEqual(300); + }); }); diff --git a/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts b/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts new file mode 100644 index 00000000..ce227039 --- /dev/null +++ b/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + fitGraphText, + graphItemLayout, + edgeLabelDimensions, + ITEM_TITLE_SIZE, + ITEM_TITLE_LINE, + type TextMeasure, +} from '../graphSymbolLayout'; + +const measure: TextMeasure = (text, size) => + Array.from(text).reduce( + (sum, char) => + sum + (char === 'W' ? 0.95 : char === 'i' ? 0.25 : 0.6) * size, + 0 + ); + +describe('graph symbol content geometry', () => { + it('fits unequal-width words and unbroken identifiers without losing an unmarked suffix', () => { + for (const value of [ + 'WWW iii WWW iii WWW', + 'PaymentGatewayRetryReconciliationHandler'.repeat(3), + '付款网关验证重试与结算协调服务'.repeat(5), + ]) { + const result = fitGraphText(value, 110, ITEM_TITLE_SIZE, measure); + expect(result.lines.length).toBeLessThanOrEqual(3); + for (const line of result.lines) + expect(measure(line, ITEM_TITLE_SIZE)).toBeLessThanOrEqual(110); + if (result.truncated) + expect(result.lines[result.lines.length - 1]).toMatch(/…$/); + } + }); + + it('makes short cards compact and reserves title, description and metadata rows', () => { + const short = graphItemLayout({ title: 'Review', type: 'TASK' }, measure); + const long = graphItemLayout( + { + title: 'Review the gateway settlement reconciliation and retry policy', + type: 'FEATURE', + description: + 'A complete readable summary with more detail available in the inspector.', + }, + measure + ); + expect(short.height).toBeLessThan(100); + expect(long.height).toBeGreaterThan(short.height); + expect(long.width).toBeLessThanOrEqual(300); + expect(long.titleY - ITEM_TITLE_SIZE / 2).toBeGreaterThan( + -long.height / 2 + 30 + ); + expect(long.descriptionY - 6).toBeGreaterThan( + long.titleY + + (long.title.length - 1) * ITEM_TITLE_LINE + + ITEM_TITLE_SIZE / 2 + ); + expect(long.footerY - 6).toBeGreaterThan(long.descriptionY + 6); + for (const line of long.title) + expect(measure(line, ITEM_TITLE_SIZE)).toBeLessThanOrEqual( + long.width - 28 + ); + }); + + it('reserves a separate row for subgraph navigation', () => { + const plain = graphItemLayout({ title: 'API', type: 'EPIC' }, measure); + const sheet = graphItemLayout( + { title: 'API', type: 'EPIC', subgraphId: 'child' }, + measure + ); + expect(sheet.height - plain.height).toBe(24); + expect(sheet.height / 2 - sheet.footerY).toBe(40); + }); + + it('reserves compact title width and height at readable zoom sizes', () => { + for (const scale of [0.3, 0.45, 0.6, 0.9]) { + const layout = graphItemLayout({ title: 'WWW PaymentGatewayRetryReconciliationHandler WWW', type: 'FEATURE' }, measure, scale); + expect(layout.compact).toBe(true); + expect(ITEM_TITLE_SIZE * layout.titleScale * scale).toBeGreaterThanOrEqual(12); + expect(layout.titleCenterY).toBe(0); + for (const line of layout.title) + expect(measure(line, ITEM_TITLE_SIZE) * layout.titleScale).toBeLessThanOrEqual(layout.width - 28); + expect(layout.title.length * ITEM_TITLE_LINE * layout.titleScale).toBeLessThan(layout.height); + } + }); + + it('rounds label ink upward and preserves horizontal and vertical padding', () => { + expect(edgeLabelDimensions(94.9, 18.2)).toEqual({ width: 135, height: 29 }); + expect(edgeLabelDimensions(0, 0)).toEqual({ width: 40, height: 26 }); + }); +}); diff --git a/packages/web/src/lib/edgeRouteGeometry.ts b/packages/web/src/lib/edgeRouteGeometry.ts index 54451402..bf22ed3e 100644 --- a/packages/web/src/lib/edgeRouteGeometry.ts +++ b/packages/web/src/lib/edgeRouteGeometry.ts @@ -23,6 +23,7 @@ export interface EdgeRouteLabel { t: number; rotation: number; iconRotation: number; + offset: number; } const finitePoint = (point: Point) => @@ -159,6 +160,60 @@ export function projectEdgeRoutePointer( return bestT; } +export function edgeRouteArrowBounds( + route: EdgeRouteGeometry, + scale = 1 +): ObstacleBox { + const angle = (route.target.angle * Math.PI) / 180; + const points = [ + [-16, -8], + [0, 0], + [-16, 8], + [-8, 0], + ].map(([x, y]) => ({ + x: route.target.x + (x * Math.cos(angle) - y * Math.sin(angle)) * scale, + y: route.target.y + (x * Math.sin(angle) + y * Math.cos(angle)) * scale, + })); + const left = Math.min(...points.map(p => p.x)), + right = Math.max(...points.map(p => p.x)); + const top = Math.min(...points.map(p => p.y)), + bottom = Math.max(...points.map(p => p.y)); + return { + x: (left + right) / 2, + y: (top + bottom) / 2, + width: right - left, + height: bottom - top, + }; +} + +export function edgeSegmentHitsBox( + segment: Pick, + box: ObstacleBox, + padding = 0 +) { + let first = 0, + last = 1; + for (const [axis, span] of [ + ['x', 'width'], + ['y', 'height'], + ] as const) { + const low = box[axis] - box[span] / 2 - padding, + high = box[axis] + box[span] / 2 + padding; + const delta = segment.target[axis] - segment.source[axis]; + if (delta === 0) { + if (segment.source[axis] < low || segment.source[axis] > high) + return false; + } else { + const a = (low - segment.source[axis]) / delta, + b = (high - segment.source[axis]) / delta; + first = Math.max(first, Math.min(a, b)); + last = Math.min(last, Math.max(a, b)); + if (first > last) return false; + } + } + return true; +} + export function edgeRouteLabel( route: EdgeRouteGeometry, options: { @@ -167,6 +222,8 @@ export function edgeRouteLabel( offset?: number; padding?: number; obstacles?: readonly ObstacleBox[]; + segments?: readonly Pick[]; + offsets?: readonly number[]; } ) { const { dims, obstacles = [] } = options; @@ -196,42 +253,54 @@ export function edgeRouteLabel( const height = Math.abs(Math.sin(radians)) * dims.width + Math.abs(Math.cos(radians)) * dims.height; - for (const distance of new Set([ - clamp(desired, low, high), - (low + high) / 2, - low, - high, - ])) { - const local = (distance - segment.start) / segment.length; - const x = - segment.source.x + - (segment.target.x - segment.source.x) * local + - Math.sin(radians) * offset; - const y = - segment.source.y + - (segment.target.y - segment.source.y) * local - - Math.cos(radians) * offset; - if ( - obstacles.some( - box => - Math.abs(x - box.x) < (width + box.width) / 2 + padding && - Math.abs(y - box.y) < (height + box.height) / 2 + padding + for (const candidateOffset of options.offsets ?? [offset]) { + if (!Number.isFinite(candidateOffset)) continue; + for (const distance of new Set([ + clamp(desired, low, high), + (low + high) / 2, + low, + high, + ])) { + const local = (distance - segment.start) / segment.length; + const x = + segment.source.x + + (segment.target.x - segment.source.x) * local + + Math.sin(radians) * candidateOffset; + const y = + segment.source.y + + (segment.target.y - segment.source.y) * local - + Math.cos(radians) * candidateOffset; + if ( + obstacles.some( + box => + Math.abs(x - box.x) < (width + box.width) / 2 + padding && + Math.abs(y - box.y) < (height + box.height) / 2 + padding + ) + ) + continue; + if ( + options.segments?.some(part => + edgeSegmentHitsBox(part, { x, y, width, height }, padding) + ) ) - ) - continue; - const delta = Math.abs(distance - desired); - if ( - delta < bestDistance || - (delta === bestDistance && distance / route.length < best!.t) - ) { - bestDistance = delta; - best = { - x, - y, - t: distance / route.length, - rotation, - iconRotation: flipped ? 180 : 0, - }; + continue; + const delta = + Math.abs(distance - desired) + + Math.abs(candidateOffset - offset) * 0.01; + if ( + delta < bestDistance || + (delta === bestDistance && distance / route.length < best!.t) + ) { + bestDistance = delta; + best = { + x, + y, + t: distance / route.length, + rotation, + iconRotation: flipped ? 180 : 0, + offset: candidateOffset, + }; + } } } } diff --git a/packages/web/src/lib/graphSymbolLayout.ts b/packages/web/src/lib/graphSymbolLayout.ts new file mode 100644 index 00000000..8e14ee63 --- /dev/null +++ b/packages/web/src/lib/graphSymbolLayout.ts @@ -0,0 +1,138 @@ +export const GRAPH_FONT_FAMILY = 'system-ui, sans-serif'; +export const ITEM_TITLE_SIZE = 16; +export const ITEM_TITLE_LINE = 21; +export const ITEM_HEADER_HEIGHT = 30; +export const EDGE_LABEL_SIZE = 12; +export const EDGE_LABEL_HEIGHT = 26; + +export type TextMeasure = ( + text: string, + size: number, + weight?: number +) => number; + +export function fitGraphText( + text: string, + width: number, + size: number, + measure: TextMeasure, + maxLines = 3 +) { + let remaining = text.replace(/\s+/g, ' ').trim(); + const lines: string[] = []; + while (remaining && lines.length < maxLines) { + if (measure(remaining, size, 600) <= width) { + lines.push(remaining); + remaining = ''; + break; + } + const last = lines.length === maxLines - 1; + const chars = Array.from(remaining); + let low = 0, + high = chars.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ( + measure( + chars.slice(0, middle).join('') + (last ? '…' : ''), + size, + 600 + ) <= width + ) + low = middle; + else high = middle - 1; + } + if (!low) return { lines: [...lines, '…'], truncated: true }; + let take = low; + const prefix = chars.slice(0, low).join(''); + const space = prefix.lastIndexOf(' '); + if (!last && space > 0) take = Array.from(prefix.slice(0, space)).length; + lines.push(chars.slice(0, take).join('').trimEnd() + (last ? '…' : '')); + remaining = chars.slice(take).join('').trimStart(); + } + return { + lines: lines.length ? lines : ['Untitled'], + truncated: remaining.length > 0, + }; +} + +export function graphItemLayout( + item: { + title: string; + type: string; + description?: string | null; + subgraphId?: string | null; + }, + measure: TextMeasure, + scale = 1 +) { + const compact = scale < 1; + const titleScale = compact + ? Math.max(1, 12 / (ITEM_TITLE_SIZE * Math.max(0.1, scale))) + : 1; + const width = Math.min( + compact ? 220 : 300, + Math.max( + 190, + measure(item.title, ITEM_TITLE_SIZE, 600) + 28, + measure(item.type, 12, 700) + 108 + ) + ); + const title = fitGraphText(item.title, (width - 28) / titleScale, ITEM_TITLE_SIZE, measure); + const description = item.description + ? fitGraphText(item.description, width - 28, 12, measure, 1).lines[0] + : ''; + const height = compact + ? title.lines.length * ITEM_TITLE_LINE * titleScale + 24 + : ITEM_HEADER_HEIGHT + + 8 + + title.lines.length * ITEM_TITLE_LINE + + (description ? 20 : 0) + + 8 + + 24 + + (item.subgraphId ? 24 : 0); + const titleY = compact + ? -(title.lines.length - 1) * ITEM_TITLE_LINE / 2 + : -height / 2 + ITEM_HEADER_HEIGHT + 8 + ITEM_TITLE_LINE / 2; + return { + width, + height, + compact, + titleScale, + title: title.lines, + truncated: title.truncated, + titleLines: title.lines.length, + maxCharsPerLine: Math.floor((width - 28) / 9), + titleY, + titleCenterY: titleY + ((title.lines.length - 1) * ITEM_TITLE_LINE) / 2, + description, + descriptionY: titleY + (title.lines.length - 1) * ITEM_TITLE_LINE + 21, + footerY: height / 2 - 16 - (item.subgraphId ? 24 : 0), + }; +} + +export function edgeLabelDimensions( + textWidth: number, + textHeight = EDGE_LABEL_SIZE +) { + return { + width: Math.ceil(textWidth) + 40, + height: Math.max(EDGE_LABEL_HEIGHT, Math.ceil(textHeight) + 10), + }; +} + +let context: CanvasRenderingContext2D | null | undefined; +export const measureGraphText: TextMeasure = (text, size, weight = 600) => { + if (context === undefined) + context = + typeof document === 'undefined' + ? null + : document.createElement('canvas').getContext('2d'); + if (!context) return Array.from(text).length * size * 0.65; + context.font = `${weight} ${size}px ${GRAPH_FONT_FAMILY}`; + const metrics = context.measureText(text); + return Math.max( + metrics.width, + (metrics.actualBoundingBoxLeft || 0) + (metrics.actualBoundingBoxRight || 0) + ); +}; From e4f654a943456c104c8e06090405c4000b048642 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 28 Oct 2026 07:32:23 -0700 Subject: [PATCH 08/19] test(graph): gate readable symbols and 2000-item zoom journeys --- ...readable-symbols-need-reserved-geometry.md | 8 + .github/workflows/ci.yml | 5 + .../InteractiveGraphVisualization.tsx | 22 +- .../lib/__tests__/edgeRouteGeometry.test.ts | 19 ++ .../lib/__tests__/graphSymbolLayout.test.ts | 21 +- packages/web/src/lib/edgeRouteGeometry.ts | 10 + packages/web/src/lib/graphSymbolLayout.ts | 12 +- tests/e2e/graph/item-symbols.spec.ts | 190 +++++++++++++++ tests/e2e/graph/large-graph-symbols.spec.ts | 217 ++++++++++++++++++ tests/e2e/graph/node-quick-edit.spec.ts | 2 +- tests/e2e/graph/semantic-lod.spec.ts | 2 +- tests/e2e/smoke/user-smoke.spec.ts | 2 +- tests/lib/itemSymbolVisual.ts | 205 +++++++++++++++++ tests/lib/runner/unified-cli.test.mjs | 11 +- tests/lib/schematicOverview.ts | 5 +- tests/sequences/unified.config.mjs | 9 +- 16 files changed, 721 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/graph/item-symbols.spec.ts create mode 100644 tests/e2e/graph/large-graph-symbols.spec.ts create mode 100644 tests/lib/itemSymbolVisual.ts diff --git a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md index e378896d..0d62b793 100644 --- a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md +++ b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md @@ -14,3 +14,11 @@ Cause: text layout, title scaling, card dimensions, route endpoints and label ad Corrective rule: measure text and reserve its displayed footprint before drawing. Cards and routes consume the same current presentation geometry; secondary rows retreat before readable primary text. Admit a complete label only when its footprint clears cards, labels, wires and arrows; expose original relationships when the drawing cannot display all labels. A small attractive fixture cannot establish large-graph acceptance: test thousands of real loaded items through zoom-out and return-to-detail, with explicit resource budgets and no viewing-induced writes. Limits: current item paths are still straight and can cross. This checkpoint does not establish full label/UI obstacle handling, all phone/zoom interactions, bounded thousands-of-item performance, or complete schematic routing. Strict browser regressions, deliberate negative controls, CI and independent review remain required in #222. Preserve earlier failed logs and never count skipped or cancelled tests as passes. + +Follow-up evidence, 2026-10-28: +- The first rendered regression sampled before the final zoom presentation transform was applied and reported undersized labels. The oracle now waits for the actual committed presentation scale and the existing opacity transition. It does not wait for arbitrary geometry violations to disappear. +- The second run classified a zero-area empty description element as a painted collision. The oracle now requires a positive painted footprint; real clipping and overlap remain failures. +- Both desktop1440 and phone390 cases then passed in22.0s with one headed Chrome, zero retries and strict fixture cleanup. Four zoom stages, keyboard compact-card editing, exact relationship access and unchanged API snapshots passed. Deliberately moving a label onto a card and shrinking a card to20px each triggered the intended oracle; restoring the DOM passed again. Retained local run directories are `item-symbols-headed1`, `item-symbols-headed2`, `item-symbols-headed3` under the owned resolution evidence directory. +- Focused geometry:19 passed. Actual unified CLI adapter/strict-evidence tests:33 passed. The added `large-symbols` sequence is headless, single-worker and zero-retry with an explicit2000-item/5000-relationship API fixture, overview clutter checks, identity/snapshot checks, detail readability and timing budgets. Discovery/enrollment is not execution; only actual CI artifacts can establish its result. + +Initial checkpoint351749 CI40704807354 failed the Grow smoke visible-name assertion: `text=.first()` resolved the newly added non-painted SVG `` tooltip. The actual API node/edge delta had passed. The selector now explicitly requires painted `.node-title-text`; node quick-edit uses the same stricter contract. The earlier CI remains a failure; this correction requires subsequent executed smoke evidence. Comprehensive40704807565 passed and does not substitute for that gate. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f92f6d60..3f0d4567 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,11 @@ jobs: env: TEST_URL: http://localhost:3127 CI: true + - name: Two thousand item headless zoom acceptance + run: node tests/run-unified.mjs --sequence large-symbols --out test-artifacts/ci-large-symbols + env: + TEST_URL: http://localhost:3127 + CI: true - name: New-user quality gate (headed under virtual display) run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence new-user --out test-artifacts/ci-new-user env: diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index d504c307..dd110f96 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -145,7 +145,7 @@ function renderItemTitle(element: SVGGElement, item: WorkItem, layout: ReturnTyp group.append('text').attr('class', 'node-title-text').attr('x', 0).attr('y', layout.titleY + index * ITEM_TITLE_LINE) .attr('text-anchor', 'middle').attr('dominant-baseline', 'middle').text(line) .style('font-family', GRAPH_FONT_FAMILY).style('font-size', `${ITEM_TITLE_SIZE}px`).style('font-weight', '600') - .style('fill', isCompletedStatus(item.status) ? '#b9c7d7' : '#f1f5f9').style('pointer-events', 'none'); + .style('fill', isCompletedStatus(item.status) ? '#b9c7d7' : '#f1f5f9').style('opacity', layout.titleVisible ? 1 : 0).style('pointer-events', 'none'); }); group.attr('transform', fittedTitleTransform(group.node()!)); } @@ -3414,7 +3414,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i element.select('.long-press-feedback').remove(); }); + let presentedScale = NaN; const updateNodePresentation = () => { + if (presentedScale === itemPresentationScaleRef.current) return; + presentedScale = itemPresentationScaleRef.current; + g.attr('data-presentation-scale', presentedScale); nodeElements.each(function(d: WorkItem) { const node = d3.select(this), layout = getNodeDimensions(d); node.attr('data-detail', layout.compact ? 'compact' : 'full').attr('role', layout.compact ? 'button' : 'group').attr('tabindex', layout.compact ? 0 : null).attr('aria-label', `${d.title}, ${getTypeConfig(d.type as WorkItemType).label}`); @@ -3444,6 +3448,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const labelRects = new Map<string, ObstacleBox>(); let routeSegments: EdgeRouteGeometry['segments'][number][] = []; let routeObstacles: ObstacleBox[] = []; + let labelViewport: ObstacleBox | undefined; + let uiLabelObstacles: ObstacleBox[] = []; const applyLabel = (element: SVGGElement, d: any, placement: EdgeRouteLabel | null) => { d._routeLabel = placement; const label = d3.select(element); @@ -3498,6 +3504,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (simulation.alpha() > 0.0015) { edgeLabelGroups.style('display', 'none'); setItemLabelCount(0); } return; } + const camera = d3.zoomTransform(svg.node()!); + const viewportRect = svg.node()!.getBoundingClientRect(); + labelViewport = { x: (viewportRect.width / 2 - camera.x) / liveK, y: (viewportRect.height / 2 - camera.y) / liveK, width: viewportRect.width / liveK, height: viewportRect.height / liveK }; + uiLabelObstacles = Array.from(document.querySelectorAll<HTMLElement>('[data-graph-fit-obstacle="true"], [data-lod-controls="true"]')).flatMap(element => { + if (getComputedStyle(element).display === 'none' || !element.getClientRects().length) return []; + const rect = element.getBoundingClientRect(); + if (!rect.width || !rect.height) return []; + return [{ x: (rect.x + rect.width / 2 - viewportRect.x - camera.x) / liveK, y: (rect.y + rect.height / 2 - viewportRect.y - camera.y) / liveK, width: rect.width / liveK, height: rect.height / liveK }]; + }); routeSegments = visibleEdges.flatMap((edge: any) => edge._route?.segments ?? []); routeObstacles = visibleEdges.filter((edge: any) => edge._route).map((edge: any) => edgeRouteArrowBounds(edge._route, arrowVisibility(liveK, edge._pTotal || 1).scale)); const placed: ObstacleBox[] = []; @@ -3509,7 +3524,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const placement = route && liveK >= 0.6 && placed.length < 32 ? edgeRouteLabel(route, { dims, t: d.labelT, offset, offsets: [offset, -offset], - obstacles: nodeLabelObstacles.concat(routeObstacles, placed), segments: routeSegments + obstacles: nodeLabelObstacles.concat(uiLabelObstacles, routeObstacles, placed), segments: routeSegments, viewport: labelViewport }) : null; d._labelBlocked = !placement; applyLabel(this, d, placement); @@ -3539,7 +3554,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const placement = edgeRouteLabel(d._route, { dims: { width: d._labelDims.width * d._labelScale, height: d._labelDims.height * d._labelScale }, t, offset: d._routeLabel?.offset ?? 25, segments: routeSegments, - obstacles: nodeLabelObstacles.concat(routeObstacles, [...labelRects].filter(([id]) => id !== d.id).map(([, box]) => box)) + viewport: labelViewport, + obstacles: nodeLabelObstacles.concat(uiLabelObstacles, routeObstacles, [...labelRects].filter(([id]) => id !== d.id).map(([, box]) => box)) }); if (placement) { d.labelTUser = true; diff --git a/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts index 5bd221f3..4f67fdc0 100644 --- a/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts +++ b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts @@ -292,4 +292,23 @@ describe('item wire geometry', () => { })!; expect(label.x + 35).toBeLessThanOrEqual(300); }); + + it('admits only full label footprints within the visible viewport', () => { + const route = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 300, y: 0 }, + ])!; + expect( + edgeRouteLabel(route, { + dims: { width: 100, height: 26 }, + viewport: { x: 150, y: -22, width: 120, height: 50 }, + }) + ).not.toBeNull(); + expect( + edgeRouteLabel(route, { + dims: { width: 100, height: 26 }, + viewport: { x: 150, y: -22, width: 80, height: 50 }, + }) + ).toBeNull(); + }); }); diff --git a/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts b/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts index ce227039..4138bc36 100644 --- a/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts +++ b/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts @@ -72,13 +72,26 @@ describe('graph symbol content geometry', () => { it('reserves compact title width and height at readable zoom sizes', () => { for (const scale of [0.3, 0.45, 0.6, 0.9]) { - const layout = graphItemLayout({ title: 'WWW PaymentGatewayRetryReconciliationHandler WWW', type: 'FEATURE' }, measure, scale); + const layout = graphItemLayout( + { + title: 'WWW PaymentGatewayRetryReconciliationHandler WWW', + type: 'FEATURE', + }, + measure, + scale + ); expect(layout.compact).toBe(true); - expect(ITEM_TITLE_SIZE * layout.titleScale * scale).toBeGreaterThanOrEqual(12); + expect( + ITEM_TITLE_SIZE * layout.titleScale * scale + ).toBeGreaterThanOrEqual(12); expect(layout.titleCenterY).toBe(0); for (const line of layout.title) - expect(measure(line, ITEM_TITLE_SIZE) * layout.titleScale).toBeLessThanOrEqual(layout.width - 28); - expect(layout.title.length * ITEM_TITLE_LINE * layout.titleScale).toBeLessThan(layout.height); + expect( + measure(line, ITEM_TITLE_SIZE) * layout.titleScale + ).toBeLessThanOrEqual(layout.width - 28); + expect( + layout.title.length * ITEM_TITLE_LINE * layout.titleScale + ).toBeLessThan(layout.height); } }); diff --git a/packages/web/src/lib/edgeRouteGeometry.ts b/packages/web/src/lib/edgeRouteGeometry.ts index bf22ed3e..7b23d6ed 100644 --- a/packages/web/src/lib/edgeRouteGeometry.ts +++ b/packages/web/src/lib/edgeRouteGeometry.ts @@ -224,6 +224,7 @@ export function edgeRouteLabel( obstacles?: readonly ObstacleBox[]; segments?: readonly Pick<EdgeRouteSegment, 'source' | 'target'>[]; offsets?: readonly number[]; + viewport?: ObstacleBox; } ) { const { dims, obstacles = [] } = options; @@ -270,6 +271,15 @@ export function edgeRouteLabel( segment.source.y + (segment.target.y - segment.source.y) * local - Math.cos(radians) * candidateOffset; + const viewport = options.viewport; + if ( + viewport && + (Math.abs(x - viewport.x) + width / 2 + padding > + viewport.width / 2 || + Math.abs(y - viewport.y) + height / 2 + padding > + viewport.height / 2) + ) + continue; if ( obstacles.some( box => diff --git a/packages/web/src/lib/graphSymbolLayout.ts b/packages/web/src/lib/graphSymbolLayout.ts index 8e14ee63..418d63a2 100644 --- a/packages/web/src/lib/graphSymbolLayout.ts +++ b/packages/web/src/lib/graphSymbolLayout.ts @@ -68,7 +68,7 @@ export function graphItemLayout( ) { const compact = scale < 1; const titleScale = compact - ? Math.max(1, 12 / (ITEM_TITLE_SIZE * Math.max(0.1, scale))) + ? Math.max(1, 12 / (ITEM_TITLE_SIZE * Math.max(0.3, scale))) : 1; const width = Math.min( compact ? 220 : 300, @@ -78,7 +78,12 @@ export function graphItemLayout( measure(item.type, 12, 700) + 108 ) ); - const title = fitGraphText(item.title, (width - 28) / titleScale, ITEM_TITLE_SIZE, measure); + const title = fitGraphText( + item.title, + (width - 28) / titleScale, + ITEM_TITLE_SIZE, + measure + ); const description = item.description ? fitGraphText(item.description, width - 28, 12, measure, 1).lines[0] : ''; @@ -92,12 +97,13 @@ export function graphItemLayout( 24 + (item.subgraphId ? 24 : 0); const titleY = compact - ? -(title.lines.length - 1) * ITEM_TITLE_LINE / 2 + ? (-(title.lines.length - 1) * ITEM_TITLE_LINE) / 2 : -height / 2 + ITEM_HEADER_HEIGHT + 8 + ITEM_TITLE_LINE / 2; return { width, height, compact, + titleVisible: scale >= 0.3, titleScale, title: title.lines, truncated: title.truncated, diff --git a/tests/e2e/graph/item-symbols.spec.ts b/tests/e2e/graph/item-symbols.spec.ts new file mode 100644 index 00000000..dfc82b13 --- /dev/null +++ b/tests/e2e/graph/item-symbols.spec.ts @@ -0,0 +1,190 @@ +import { test as base, expect } from '@playwright/test'; +import { login, TEST_USERS } from '../../lib/auth'; +import { gql } from '../../lib/graphRequest'; +import { + ownedGraphIdsFixture, + type OwnedGraphs, +} from '../../lib/ownedGraphIdsFixture'; +import { + inspectItemSymbols, + readItemSymbols, +} from '../../lib/itemSymbolVisual'; + +const test = base.extend<{ ownedGraphIds: OwnedGraphs }>({ + ownedGraphIds: [ownedGraphIdsFixture, { timeout: 60000 }], +}); +test.use({ actionTimeout: 10000 }); + +for (const width of [1440, 390]) + test.describe(`${width}px symbols`, () => { + test.use({ viewport: { width, height: 900 } }); + test('cards and labels reserve readable content through zoom @item-symbols', async ({ + page, + ownedGraphIds, + }, info) => { + test.setTimeout(120000); + await login(page, TEST_USERS.ADMIN); + const seeded = await ownedGraphIds.seed({ + size: 2, + spacing: 600, + edgeFactor: 0, + namePrefix: 'Readable symbols', + titleForIndex: index => + index + ? 'PaymentGatewayRetryReconciliationHandler with settlement validation and WWW identifiers' + : 'Review', + additionalEdges: [{ sourceIndex: 1, targetIndex: 0, type: 'BLOCKS' }], + }); + const snapshot = async () => { + const data = await gql( + page, + 'query($id:ID!){workItems(where:{graph:{id:$id}}){id title positionX positionY} edges(where:{source:{graph:{id:$id}}}){id type source{id} target{id}}}', + { id: seeded.graphId } + ); + data.workItems.sort((a: { id: string }, b: { id: string }) => + a.id.localeCompare(b.id) + ); + data.edges.sort((a: { id: string }, b: { id: string }) => + a.id.localeCompare(b.id) + ); + return data; + }; + const before = await snapshot(), + writes: string[] = [], + errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('**/api/graphql', async route => { + if ( + /^\s*mutation\b/.test(route.request().postDataJSON()?.query ?? '') + ) { + writes.push( + route.request().postDataJSON().operationName ?? 'mutation' + ); + await route.abort(); + } else await route.continue(); + }); + await page.evaluate(id => { + localStorage.setItem('currentGraphId', id); + localStorage.setItem('graphdone:viewMode', 'graph'); + }, seeded.graphId); + await page.goto('/'); + await expect(page.locator('.graph-container .node')).toHaveCount(2); + await expect + .poll(() => + page.evaluate(() => (window as any).__layoutMetrics?.()?.cameraReady) + ) + .toBe(true); + const target = before.workItems.find( + (item: { id: string }) => item.id === seeded.nodeIds[1] + ); + for (const scale of [0.6, 0.9, 1.2, 0.6]) { + await page.evaluate( + ({ x, y, scale }) => (window as any).miniMapZoom(x, y, scale), + { + x: width === 390 ? target.positionX : -300, + y: target.positionY, + scale, + } + ); + await expect + .poll(() => + page + .locator('.graph-container > svg') + .evaluate(element => (element as any).__zoom.k) + ) + .toBeCloseTo(scale); + await expect + .poll(() => page.locator('.node').first().getAttribute('data-detail')) + .toBe(scale < 1 ? 'compact' : 'full'); + await expect(page.locator('.main-graph-group')).toHaveAttribute( + 'data-presentation-scale', + String(scale) + ); + const geometry = await inspectItemSymbols( + page, + info, + `symbols-${width}-${scale}` + ); + if (width === 1440) expect(geometry.labels.length).toBe(2); + } + const targetNode = page + .locator('.node') + .filter({ + has: page.locator('.node-title-group > title', { + hasText: target.title, + }), + }); + await targetNode.focus(); + await page.keyboard.press('Enter'); + await expect( + page.getByRole('dialog', { name: 'Edit work item', exact: true }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + await page + .getByRole('button', { + name: /2 relationships, .*Browse all relationships/, + }) + .click(); + await expect( + page.getByRole('region', { name: 'Graph relationships' }) + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /^Edit relationship / }) + ).toHaveCount(2); + await page + .getByRole('button', { name: 'Back to graph', exact: true }) + .click(); + if (width === 1440) { + const saved = await page + .locator('.edge-label-group:visible') + .first() + .getAttribute('transform'); + await page + .locator('.edge-label-group:visible') + .first() + .evaluate(label => { + const node = document.querySelector('.node') as any; + label.setAttribute( + 'transform', + `translate(${node.__data__.x},${node.__data__.y})` + ); + }); + const negative = await readItemSymbols(page); + expect( + negative.violations.some(value => value.startsWith('label-overlap:')) + ).toBe(true); + await info.attach('deliberate-overlap.json', { + body: JSON.stringify(negative), + contentType: 'application/json', + }); + await page + .locator('.edge-label-group:visible') + .first() + .evaluate( + (label, transform) => label.setAttribute('transform', transform!), + saved + ); + const original = await targetNode + .locator('.node-bg') + .getAttribute('width'); + await targetNode + .locator('.node-bg') + .evaluate(element => element.setAttribute('width', '20')); + expect( + (await readItemSymbols(page)).violations.some(value => + value.startsWith('title-clipped:') + ) + ).toBe(true); + await targetNode + .locator('.node-bg') + .evaluate( + (element, value) => element.setAttribute('width', value!), + original + ); + await inspectItemSymbols(page, info, 'restored-after-negatives'); + } + expect(await snapshot()).toEqual(before); + expect(writes).toEqual([]); + expect(errors).toEqual([]); + }); + }); diff --git a/tests/e2e/graph/large-graph-symbols.spec.ts b/tests/e2e/graph/large-graph-symbols.spec.ts new file mode 100644 index 00000000..9c56af06 --- /dev/null +++ b/tests/e2e/graph/large-graph-symbols.spec.ts @@ -0,0 +1,217 @@ +import { test as base, expect } from '@playwright/test'; +import { login, TEST_USERS } from '../../lib/auth'; +import { gql } from '../../lib/graphRequest'; +import { + ownedGraphIdsFixture, + type OwnedGraphs, +} from '../../lib/ownedGraphIdsFixture'; +import type { SeededGraph } from '../../lib/seedGraph'; +import { inspectItemSymbols } from '../../lib/itemSymbolVisual'; +import { inspectSchematicOverview } from '../../lib/schematicOverview'; + +const SIZE = 2000; +const test = base.extend<{ + ownedGraphIds: OwnedGraphs; + largeGraph: SeededGraph; +}>({ + ownedGraphIds: [ownedGraphIdsFixture, { timeout: 180000 }], + largeGraph: [ + async ({ page, ownedGraphIds }, use) => { + await login(page, TEST_USERS.ADMIN); + const seeded = await ownedGraphIds.seed({ + size: SIZE, + spacing: 420, + edgeFactor: 1.5, + batchSize: 20, + namePrefix: 'Two thousand item zoom', + titleForIndex: (index, type) => + `${type} ${index} ${index % 7 === 0 ? 'PaymentGatewayRetryReconciliationHandler' : 'worker'}`, + metadataForIndex: index => ({ + lod: { + groupPath: [ + `Subsystem ${Math.floor(index / 250)}`, + `Service ${Math.floor(index / 50) % 5}`, + ], + }, + }), + additionalEdges: Array.from({ length: 2000 }, (_, index) => ({ + sourceIndex: index, + targetIndex: (index + 17) % SIZE, + type: index % 2 ? 'BLOCKS' : 'ENABLES', + })), + }); + await gql( + page, + 'mutation($id:ID!){updateWorkItems(where:{graph:{id:$id},positionX:0,positionY:0},update:{positionX:1}){workItems{id}}}', + { id: seeded.graphId } + ); + await use(seeded); + }, + { timeout: 600000 }, + ], +}); +test.use({ + viewport: { width: 1440, height: 900 }, + headless: true, + actionTimeout: 10000, + video: 'on', +}); + +test('2000 loaded items and 5000 relationships survive overview and detail zoom @large-symbols', async ({ + page, + largeGraph, +}, info) => { + test.setTimeout(180000); + const snapshot = async () => { + const data = await gql( + page, + 'query($id:ID!){workItems(where:{graph:{id:$id}}){id title positionX positionY metadata} edges(where:{source:{graph:{id:$id}}}){id type source{id} target{id}}}', + { id: largeGraph.graphId } + ); + data.workItems.sort((a: { id: string }, b: { id: string }) => + a.id.localeCompare(b.id) + ); + data.edges.sort((a: { id: string }, b: { id: string }) => + a.id.localeCompare(b.id) + ); + return data; + }; + const before = await snapshot(); + expect(before.workItems).toHaveLength(SIZE); + expect(before.edges).toHaveLength(5000); + const writes: string[] = [], + errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('**/api/graphql', async route => { + if (/^\s*mutation\b/.test(route.request().postDataJSON()?.query ?? '')) { + writes.push(route.request().postDataJSON().operationName ?? 'mutation'); + await route.abort(); + } else await route.continue(); + }); + await page.evaluate(id => { + localStorage.setItem('currentGraphId', id); + localStorage.setItem('graphdone:viewMode', 'graph'); + }, largeGraph.graphId); + const loadStart = Date.now(); + await page.goto('/'); + await expect(page.locator('.graph-container .node')).toHaveCount(SIZE, { + timeout: 45000, + }); + await expect + .poll( + () => + page.evaluate(() => (window as any).__layoutMetrics?.()?.cameraReady), + { timeout: 15000 } + ) + .toBe(true); + const loadedMs = Date.now() - loadStart; + expect(loadedMs).toBeLessThan(45000); + const allIds = await page + .locator('.node') + .evaluateAll(nodes => nodes.map(node => (node as any).__data__.id).sort()); + expect(allIds).toEqual([...largeGraph.nodeIds].sort()); + expect( + await page + .locator('.edges-group .edge') + .evaluateAll(edges => + edges.map(edge => edge.getAttribute('data-edge-id')).sort() + ) + ).toEqual(before.edges.map((edge: { id: string }) => edge.id)); + const overview = page.locator('.semantic-overview'); + await expect(overview).toHaveAttribute('data-active', 'true'); + await inspectSchematicOverview(overview, info, 5000, '2000-items-fit', 8); + const target = before.workItems.find( + (item: { id: string }) => item.id === largeGraph.nodeIds[1008] + ); + const timings: { + scale: number; + ms: number; + paintedCards?: number; + labels?: number; + }[] = []; + for (const scale of [1.2, 0.08, 1.2]) { + const start = Date.now(); + await page.evaluate( + ({ x, y, scale }) => (window as any).miniMapZoom(x, y, scale), + { x: target.positionX, y: target.positionY, scale } + ); + await expect + .poll(() => + page + .locator('.graph-container > svg') + .evaluate(element => (element as any).__zoom.k) + ) + .toBeCloseTo(scale); + await expect(overview).toHaveAttribute( + 'data-active', + scale < 1 ? 'true' : 'false' + ); + await expect(page.locator('.main-graph-group')).toHaveAttribute( + 'data-presentation-scale', + String(scale) + ); + const elapsed = Date.now() - start; + expect( + elapsed, + 'Zoom must finish within five seconds on the CI runner' + ).toBeLessThan(5000); + if (scale > 1) { + const geometry = await inspectItemSymbols( + page, + info, + `2000-detail-${timings.length}` + ); + expect(geometry.cards.length).toBeLessThan(100); + expect(geometry.labels.length).toBeGreaterThan(0); + expect(geometry.labels.length).toBeLessThanOrEqual(32); + timings.push({ + scale, + ms: elapsed, + paintedCards: geometry.cards.length, + labels: geometry.labels.length, + }); + } else { + await inspectSchematicOverview( + overview, + info, + 5000, + '2000-items-zoomed-out', + 8 + ); + timings.push({ scale, ms: elapsed }); + } + } + await page + .getByRole('button', { + name: /5000 relationships, .*Browse all relationships/, + }) + .click(); + const relationships = page.getByRole('region', { + name: 'Graph relationships', + }); + await expect(relationships).toBeVisible(); + await relationships.getByRole('textbox').fill(before.edges[0].id); + await expect( + relationships.getByRole('button', { + name: `Edit relationship ${before.edges[0].id}`, + exact: true, + }) + ).toBeVisible(); + await page + .getByRole('button', { name: 'Back to graph', exact: true }) + .click(); + expect(await snapshot()).toEqual(before); + expect(writes).toEqual([]); + expect(errors).toEqual([]); + await info.attach('two-thousand-node-performance.json', { + body: JSON.stringify({ + nodes: SIZE, + edges: 5000, + loadedMs, + timings, + writes, + errors, + }), + contentType: 'application/json', + }); +}); diff --git a/tests/e2e/graph/node-quick-edit.spec.ts b/tests/e2e/graph/node-quick-edit.spec.ts index c54f393d..30db718d 100644 --- a/tests/e2e/graph/node-quick-edit.spec.ts +++ b/tests/e2e/graph/node-quick-edit.spec.ts @@ -46,7 +46,7 @@ test.describe('node quick-edit @quickedit', () => { await page.waitForTimeout(1200); // mutation + graph refresh // The graph should now show the new title somewhere. - await expect(page.locator('.graph-container svg').getByText(marker, { exact: false }).first()).toBeVisible({ timeout: 8000 }); + await expect(page.locator('.graph-container .node-title-text').filter({ hasText: marker }).first()).toBeVisible({ timeout: 8000 }); // Revert so the seed data is unchanged. if (original) { diff --git a/tests/e2e/graph/semantic-lod.spec.ts b/tests/e2e/graph/semantic-lod.spec.ts index 3a483779..2282f19e 100644 --- a/tests/e2e/graph/semantic-lod.spec.ts +++ b/tests/e2e/graph/semantic-lod.spec.ts @@ -252,7 +252,7 @@ for (const width of [1440, 390]) const camera = (element as SVGSVGElement & { __zoom: { x: number; y: number; k: number } }).__zoom; return { x: camera.x, y: camera.y, k: camera.k }; })).toEqual(fixedCamera); - await expect.poll(() => page.evaluate(() => (window as any).__lodVisibleTitleMeasurements)).toBeGreaterThan(0); + await expect.poll(() => page.evaluate(() => (window as any).__lodVisibleTitleMeasurements)).toBe(0); expect(await page.evaluate(() => (window as any).__lodCameraRefreshCount)).toBe(0); await page.evaluate(() => { const target = window as any; diff --git a/tests/e2e/smoke/user-smoke.spec.ts b/tests/e2e/smoke/user-smoke.spec.ts index 82b4c60a..2f04c6f6 100644 --- a/tests/e2e/smoke/user-smoke.spec.ts +++ b/tests/e2e/smoke/user-smoke.spec.ts @@ -230,7 +230,7 @@ test.describe('user smoke: the app works from a user point of view @smoke', () = expect(after.nodes, 'a node must be created').toBe(before.nodes + 1); expect(after.edges, 'a connecting edge must be created').toBe(before.edges + 1); // ...and the user actually sees the newly named node on the canvas. - await expect(page.locator(`text=${name}`).first()).toBeVisible(); + await expect(page.locator('.node-title-text').filter({ hasText: name }).first()).toBeVisible(); // Undo must walk it back: Ctrl+Z undoes the rename, then the creation. await page.keyboard.press('Control+z'); diff --git a/tests/lib/itemSymbolVisual.ts b/tests/lib/itemSymbolVisual.ts new file mode 100644 index 00000000..1b3e0544 --- /dev/null +++ b/tests/lib/itemSymbolVisual.ts @@ -0,0 +1,205 @@ +import { expect, type Page, type TestInfo } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; + +export async function readItemSymbols(page: Page) { + return page.locator('.graph-container').evaluate(host => { + const rect = (element: Element) => { + const box = element.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }; + const overlaps = (a: ReturnType<typeof rect>, b: ReturnType<typeof rect>) => + a.x < b.x + b.width - 0.5 && + a.x + a.width > b.x + 0.5 && + a.y < b.y + b.height - 0.5 && + a.y + a.height > b.y + 0.5; + const contains = (a: ReturnType<typeof rect>, b: ReturnType<typeof rect>) => + b.x >= a.x - 0.5 && + b.y >= a.y - 0.5 && + b.x + b.width <= a.x + a.width + 0.5 && + b.y + b.height <= a.y + a.height + 0.5; + const viewport = rect(host); + const visible = (element: Element) => { + for ( + let current: Element | null = element; + current; + current = current.parentElement + ) { + const style = getComputedStyle(current); + if ( + style.display === 'none' || + style.visibility !== 'visible' || + Number(style.opacity) === 0 + ) + return false; + } + const bounds = rect(element); + return ( + bounds.width > 0 && bounds.height > 0 && overlaps(bounds, viewport) + ); + }; + const font = (element: SVGGraphicsElement) => { + const matrix = element.getScreenCTM()!; + return ( + parseFloat(getComputedStyle(element).fontSize) * + Math.hypot(matrix.a, matrix.b) + ); + }; + const cards = Array.from(host.querySelectorAll<SVGGElement>('.node')) + .filter(visible) + .map(node => ({ + id: (node as any).__data__.id as string, + detail: node.getAttribute('data-detail'), + box: rect(node.querySelector('.node-bg')!), + titles: Array.from( + node.querySelectorAll<SVGGraphicsElement>('.node-title-text') + ) + .filter(visible) + .map(text => ({ + text: text.textContent, + box: rect(text), + font: font(text), + })), + parts: Array.from( + node.querySelectorAll( + '.node-type-text, .node-edit-icon, .node-relationship-icon, .node-expand-icon, .node-title-text, .node-description-text, .node-meta text' + ) + ) + .filter(visible) + .map(part => ({ kind: part.getAttribute('class'), box: rect(part) })), + })); + const labels = Array.from( + host.querySelectorAll<SVGGElement>('.edge-label-group') + ) + .filter(visible) + .map(label => { + const text = label.querySelector<SVGGraphicsElement>('.edge-label')!; + return { + id: (label as any).__data__.id as string, + box: rect(label.querySelector('rect')!), + text: rect(text), + font: font(text), + opacity: + Number(getComputedStyle(label).opacity) * + Number(getComputedStyle(text).opacity), + }; + }); + const arrows = Array.from(host.querySelectorAll('.arrows-group .arrow')) + .filter(visible) + .map(rect); + const controls = Array.from( + document.querySelectorAll( + '[data-graph-fit-obstacle="true"], [data-lod-controls="true"]' + ) + ) + .filter(visible) + .map(rect); + const segments = Array.from( + host.querySelectorAll<SVGPathElement>('.edges-group .edge') + ) + .filter(visible) + .flatMap(path => { + const matrix = path.getScreenCTM()!; + const points = (path.getAttribute('d')?.match(/[ML][^ML]+/g) ?? []).map( + command => { + const [x, y] = command.slice(1).split(',').map(Number); + const point = new DOMPoint(x, y).matrixTransform(matrix); + return { x: point.x, y: point.y }; + } + ); + return points + .slice(1) + .map((target, index) => ({ source: points[index], target })); + }); + const wireHits = ( + line: (typeof segments)[number], + box: ReturnType<typeof rect> + ) => { + let low = 0, + high = 1; + for (const [axis, span] of [ + ['x', 'width'], + ['y', 'height'], + ] as const) { + const delta = line.target[axis] - line.source[axis]; + const min = box[axis] + 0.5, + max = box[axis] + box[span] - 0.5; + if (!delta) { + if (line.source[axis] < min || line.source[axis] > max) return false; + } else { + const a = (min - line.source[axis]) / delta, + b = (max - line.source[axis]) / delta; + low = Math.max(low, Math.min(a, b)); + high = Math.min(high, Math.max(a, b)); + if (low > high) return false; + } + } + return true; + }; + const violations: string[] = []; + for (const card of cards) { + for (const title of card.titles) { + if (!contains(card.box, title.box)) + violations.push(`title-clipped:${card.id}`); + if (title.font < 11.9) violations.push(`title-small:${card.id}`); + } + for (const [index, part] of card.parts.entries()) { + if (!contains(card.box, part.box)) + violations.push(`card-part-clipped:${card.id}:${part.kind}`); + for (const other of card.parts.slice(index + 1)) + if (overlaps(part.box, other.box)) + violations.push( + `card-part-overlap:${card.id}:${part.kind}:${other.kind}` + ); + } + } + for (const [index, label] of labels.entries()) { + if (!contains(label.box, label.text)) + violations.push(`label-clipped:${label.id}`); + if (!contains(viewport, label.box)) + violations.push(`label-offscreen:${label.id}`); + if (label.font < 11.9 || label.opacity < 0.99) + violations.push(`label-unreadable:${label.id}`); + if ( + [ + ...cards.map(card => card.box), + ...arrows, + ...controls, + ...labels.slice(index + 1).map(other => other.box), + ].some(box => overlaps(label.box, box)) + ) + violations.push(`label-overlap:${label.id}`); + if (segments.some(segment => wireHits(segment, label.box))) + violations.push(`label-wire-overlap:${label.id}`); + } + return { viewport, cards, labels, segments, violations }; + }); +} + +export async function inspectItemSymbols( + page: Page, + info: TestInfo, + stage: string +) { + await expect + .poll(async () => + (await readItemSymbols(page)).labels.every(label => label.opacity >= 0.99) + ) + .toBe(true); + const geometry = await readItemSymbols(page); + const json = info.outputPath(`${stage}.json`), + png = info.outputPath(`${stage}.png`); + await writeFile(json, JSON.stringify(geometry)); + await info.attach(`${stage}.json`, { + path: json, + contentType: 'application/json', + }); + await page.screenshot({ path: png }); + await info.attach(`${stage}.png`, { path: png, contentType: 'image/png' }); + expect( + geometry.cards.length, + 'The detail view must paint useful item content' + ).toBeGreaterThan(0); + expect(geometry.cards.flatMap(card => card.titles).length).toBeGreaterThan(0); + expect(geometry.violations).toEqual([]); + return geometry; +} diff --git a/tests/lib/runner/unified-cli.test.mjs b/tests/lib/runner/unified-cli.test.mjs index b4845bae..b3df7887 100644 --- a/tests/lib/runner/unified-cli.test.mjs +++ b/tests/lib/runner/unified-cli.test.mjs @@ -46,7 +46,7 @@ test('disposable Git fixture commits do not spawn background maintenance', t => assert.deepEqual(events.filter(event => event.event === 'child_start' && event.argv?.includes('maintenance')), []); }); -for (const sequence of ['new-user', 'semantic-lod', 'nested-lod', 'item-wires', 'schematic-visual', 'layout-recovery', 'router-navigation', 'mobile', 'perf-budgets']) { +for (const sequence of ['new-user', 'semantic-lod', 'nested-lod', 'item-wires', 'large-symbols', 'schematic-visual', 'layout-recovery', 'router-navigation', 'mobile', 'perf-budgets']) { test(`CLI forwards failure-only tracing without retries for ${sequence}`, t => { const { dir, run } = fixture(t); const report = { suites: [{ specs: [{ title: 'fixture', ok: true, @@ -60,6 +60,14 @@ for (const sequence of ['new-user', 'semantic-lod', 'nested-lod', 'item-wires', assert.ok(!args.includes('tests/e2e/graph/semantic-lod.spec.ts')); assert.ok(!args.includes('tests/e2e/auth/router-navigation.spec.ts')); } + if (sequence === 'large-symbols') { + assert.ok(args.includes('tests/e2e/graph/large-graph-symbols.spec.ts')); + assert.ok(!args.includes('--headed')); + assert.equal(SEQUENCES[sequence].blocking, true); + for (const profile of ['pr', 'full']) assert.ok(PROFILES[profile].includes(sequence)); + const workflow = readFileSync(join(root, '.github/workflows/ci.yml'), 'utf8'); + assert.equal(workflow.split('run: node tests/run-unified.mjs --sequence large-symbols --out test-artifacts/ci-large-symbols').length, 2); + } if (sequence === 'schematic-visual') { assert.ok(args.includes('--config=playwright.schematic.config.ts')); assert.equal(SEQUENCES[sequence].blocking, true); @@ -70,6 +78,7 @@ for (const sequence of ['new-user', 'semantic-lod', 'nested-lod', 'item-wires', if (sequence === 'item-wires' || sequence === 'nested-lod' || sequence === 'semantic-lod' || sequence === 'layout-recovery' || sequence === 'router-navigation') { const spec = sequence === 'item-wires' ? 'tests/e2e/graph/item-wire-consumers.spec.ts' : sequence === 'nested-lod' ? 'tests/e2e/graph/nested-semantic-lod.spec.ts' : sequence === 'semantic-lod' ? 'tests/e2e/graph/semantic-lod.spec.ts' : sequence === 'layout-recovery' ? 'tests/e2e/graph/layout-recovery.spec.ts' : 'tests/e2e/auth/router-navigation.spec.ts'; assert.ok(args.includes(spec)); + if (sequence === 'item-wires') assert.ok(args.includes('tests/e2e/graph/item-symbols.spec.ts')); assert.equal(SEQUENCES[sequence].blocking, true); const workflow = readFileSync(join(root, '.github/workflows/ci.yml'), 'utf8'); const command = `run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence ${sequence} --out test-artifacts/ci-${sequence}`; diff --git a/tests/lib/schematicOverview.ts b/tests/lib/schematicOverview.ts index 9162efa0..7ef236d1 100644 --- a/tests/lib/schematicOverview.ts +++ b/tests/lib/schematicOverview.ts @@ -9,7 +9,8 @@ export async function inspectSchematicOverview( overview: Locator, info: TestInfo, relationships: number, - stage = 'overview' + stage = 'overview', + expectedCards = 2 ) { const read = () => overview.evaluate(host => { @@ -98,7 +99,7 @@ export async function inspectSchematicOverview( }, }; }); - await expect.poll(async () => (await read()).cards.length).toBe(2); + await expect.poll(async () => (await read()).cards.length).toBe(expectedCards); await expect .poll(async () => (await read()).routes.length) .toBeGreaterThan(0); diff --git a/tests/sequences/unified.config.mjs b/tests/sequences/unified.config.mjs index f7757427..afd2c3de 100644 --- a/tests/sequences/unified.config.mjs +++ b/tests/sequences/unified.config.mjs @@ -36,7 +36,10 @@ export const SEQUENCES = { 'tests/e2e/graph/nested-semantic-lod.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', ], blocking: true }, 'item-wires': { adapter: 'playwright', title: 'Item wire consumer acceptance', args: [ - 'tests/e2e/graph/item-wire-consumers.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', + 'tests/e2e/graph/item-wire-consumers.spec.ts', 'tests/e2e/graph/item-symbols.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', + ], blocking: true }, + 'large-symbols': { adapter: 'playwright', title: 'Two thousand item zoom acceptance', args: [ + 'tests/e2e/graph/large-graph-symbols.spec.ts', `--project=${PW}`, '--workers=1', '--retries=0', '--trace=retain-on-failure', ], blocking: true }, 'schematic-visual': { adapter: 'playwright', title: 'Schematic fixture visual acceptance', args: [ '--config=playwright.schematic.config.ts', '--workers=1', '--retries=0', '--trace=retain-on-failure', @@ -68,8 +71,8 @@ export const SEQUENCES = { export const PROFILES = { smoke: ['unit-web', 'smoke', 'e2e-auth'], - pr: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'nested-lod', 'item-wires', 'schematic-visual', 'layout-recovery', 'router-navigation', 'mobile', 'perf-budgets'], - full: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'nested-lod', 'item-wires', 'schematic-visual', 'layout-recovery', 'router-navigation', 'mobile', 'perf-budgets', 'diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], + pr: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'nested-lod', 'item-wires', 'large-symbols', 'schematic-visual', 'layout-recovery', 'router-navigation', 'mobile', 'perf-budgets'], + full: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'nested-lod', 'item-wires', 'large-symbols', 'schematic-visual', 'layout-recovery', 'router-navigation', 'mobile', 'perf-budgets', 'diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], report: ['diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], live: ['live-journey'], }; From 6d817521a4fa92c9beeedcf6bddbdc7276837c0f Mon Sep 17 00:00:00 2001 From: Matthew Valancy <matthew.valancy@gmail.com> Date: Wed, 28 Oct 2026 07:50:18 -0700 Subject: [PATCH 09/19] fix(graph): bound label work and preserve visible interactions --- ...readable-symbols-need-reserved-geometry.md | 8 ++ .../InteractiveGraphVisualization.tsx | 120 +++++++----------- .../SemanticRelationshipBrowser.tsx | 49 ++++++- .../components/SemanticRelationships.test.tsx | 48 +++++++ .../lib/__tests__/edgeLabelPlacement.test.ts | 78 ++++++++++++ packages/web/src/lib/edgeLabelPlacement.ts | 84 ++++++++++++ packages/web/src/lib/edgeRouteGeometry.ts | 41 ++++-- tests/e2e/graph/item-symbols.spec.ts | 23 +++- tests/e2e/graph/large-graph-symbols.spec.ts | 44 ++++++- 9 files changed, 393 insertions(+), 102 deletions(-) create mode 100644 packages/web/src/lib/__tests__/edgeLabelPlacement.test.ts create mode 100644 packages/web/src/lib/edgeLabelPlacement.ts diff --git a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md index 0d62b793..8515e47a 100644 --- a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md +++ b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md @@ -22,3 +22,11 @@ Follow-up evidence, 2026-10-28: - Focused geometry:19 passed. Actual unified CLI adapter/strict-evidence tests:33 passed. The added `large-symbols` sequence is headless, single-worker and zero-retry with an explicit2000-item/5000-relationship API fixture, overview clutter checks, identity/snapshot checks, detail readability and timing budgets. Discovery/enrollment is not execution; only actual CI artifacts can establish its result. Initial checkpoint351749 CI40704807354 failed the Grow smoke visible-name assertion: `text=<name>.first()` resolved the newly added non-painted SVG `<title>` tooltip. The actual API node/edge delta had passed. The selector now explicitly requires painted `.node-title-text`; node quick-edit uses the same stricter contract. The earlier CI remains a failure; this correction requires subsequent executed smoke evidence. Comprehensive40704807565 passed and does not substitute for that gate. + + +Independent review and executed scale evidence, 2026-10-28: +- PR223 review5684569576 identified an unreachable0.08 test zoom (the real minimap clamps at0.1), unbounded failed label placement attempts, culled labels consuming visible slots/counts, and lost Browse return focus. All four are corrected in the next checkpoint. Pure regression fixtures exercise2000 failed candidates, a total collision-check cutoff and2000 hidden candidates preceding one visible label. The production placement helper has32 labels,128 attempts and200000 obstacle/segment checks as invocation-wide limits; partial checks never admit a label. +- Exact e4 CI40706957761 actually loaded2000 items and5000 edges and passed the eight-group overview clutter/accounting checks. Its first return-to-detail took5847ms against the5000ms gate and failed; the failure screenshot showed zero admitted labels. This remains a failure, not scale acceptance. Corrective work filters placement candidates/obstacles to the visible drawing, prevents hidden labels consuming slots, reuses presentation for unchanged items and rebuilds card content only for uncullled items. The old middle zoom target was not reached in this failed run. +- CI timing JSON now uses retained file attachments, including the timing before a failing budget assertion. The unchanged five-second budget and minimum visible-content assertions must pass in a subsequent actual run; they were not relaxed. +- Complete local headed smoke:6 passed in2.0min (single worker, zero retries). Corrected focus/placement desktop+phone cases:2 passed in22.0s, including Browse Back/Escape focus and compact-editor focus return. Geometry/budget cases:22 passed. Relationship UI cases:14 passed; an initial pagination regression exceeded5s because global accessibility queries scanned1250 buttons, then navigation-scoped queries passed without increasing the timeout. +- Exact relationship access renders at most250 rows per page, with full-result search and explicit original counts. This bounds the new5000-edge browse path; pagination itself does not prove the complete large browser journey. diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index dd110f96..0dd54b99 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -52,14 +52,15 @@ import { useAdaptiveQuality } from '../hooks/useAdaptiveQuality'; import { nodeLifeClasses, nodeGlowFilter, isActiveStatus, isCompletedStatus, edgeFlowClass } from '../lib/nodeAnimations'; import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerge'; import type { ObstacleBox } from '../lib/edgeLabelLayout'; -import { createParallelEdgeRoute, edgeRouteLabel, edgeRouteArrowBounds, projectEdgeRoutePointer, type EdgeRouteGeometry, type EdgeRouteLabel } from '../lib/edgeRouteGeometry'; +import { createParallelEdgeRoute, edgeRouteLabel, edgeRouteArrowBounds, edgeSegmentHitsBox, projectEdgeRoutePointer, type EdgeRouteGeometry, type EdgeRouteLabel } from '../lib/edgeRouteGeometry'; import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { graphItemLayout, measureGraphText, edgeLabelDimensions, GRAPH_FONT_FAMILY, ITEM_TITLE_SIZE, ITEM_TITLE_LINE, ITEM_HEADER_HEIGHT, EDGE_LABEL_SIZE } from '../lib/graphSymbolLayout'; +import { placeItemLabels } from '../lib/edgeLabelPlacement'; import { separateCards } from '../lib/separateCards'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; import { computeCompactLayout, isHierarchicalLayout, parseLayoutMode, LAYOUT_MODE_STORAGE_KEY, type LayoutMode } from '../lib/layoutAlgorithms'; import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry'; -import { directionStrategy, arrowVisibility, labelVisibility, perpendicularOffset } from '../lib/edgeLOD'; +import { directionStrategy, arrowVisibility, perpendicularOffset } from '../lib/edgeLOD'; import { assignParallelEdgeIndices } from '../lib/parallelEdges'; import { isTap, touchHitSize, isCoarsePointer } from '../lib/touchInteraction'; import { usePositionSaveQueue } from '../hooks/usePositionSaveQueue'; @@ -102,34 +103,6 @@ const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number return (scale - (threshold - fadeRange)) / (fadeRange * 2); }; -// PR-4 legibility floor: zoom does double duty in this app — it's both spatial -// navigation AND the readability control (text is rendered at a fixed font size, -// so the parent `g` zoom scales it down with everything else). Past a certain -// zoom-out the primary label is sub-readable. Rather than forcing the user to -// zoom IN to read a label, counter-scale the title/type so their ON-SCREEN size -// never drops below a readable floor within the working band. Beyond the band -// the existing LOD opacity cull still hides them for overview/perf. -const TYPE_BASE_PX = 13; // matches .node-type-text font-size -const LEGIBLE_FLOOR_PX = 12; // smallest on-screen size we keep text at -const LEGIBLE_MAX_BOOST = 1.9; // cap the counter-scale so text never balloons -// Title appears (and gets the floor) from this zoom — lower than the old -// title-visible threshold so the label is readable across the working band, not -// only when zoomed all the way in. Perf-safe: dense graphs hide node text via -// the data-simplify CSS below SIMPLIFY_SCALE (0.45) regardless of opacity. -const TITLE_VISIBLE_SCALE = 0.4; -const legibilityScale = (basePx: number, k: number) => { - if (!k || k <= 0) return 1; - return Math.min(LEGIBLE_MAX_BOOST, Math.max(1, LEGIBLE_FLOOR_PX / (basePx * k))); -}; -// A scale about a stored vertical anchor (data-cy) so multi-line title blocks -// grow/shrink as a unit (font AND line-spacing together → never overlap). -const legibilityTransform = (cy: number, basePx: number, k: number) => { - const s = legibilityScale(basePx, k); - return `translate(0,${cy}) scale(${s}) translate(0,${-cy})`; -}; - -// The legibility boost must never let text escape its card. The full title -// remains available in the editor; at overview zoom the card is the boundary. function fittedTitleTransform(element: SVGGElement): string { const cy = Number(element.getAttribute('data-cy')) || 0; const scale = Number(element.getAttribute('data-title-scale')) || 1; @@ -288,7 +261,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i containerRef.current?.querySelectorAll<SVGGElement>('.node-expand-icon') ?? [] ).find(element => element.dataset.nodeId === returnFocusId); trigger?.setAttribute('aria-expanded', 'false'); - trigger?.focus({ preventScroll: true }); + const card = trigger?.closest<SVGGElement>('.node'); + (card?.getAttribute('data-detail') === 'compact' ? card : trigger)?.focus({ preventScroll: true }); }); }, []); const toggleExpandedNode = useCallback((n: WorkItem) => { @@ -482,6 +456,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const [selectedEdge, setSelectedEdge] = useState<WorkItemEdge | null>(null); const [itemLabelCount, setItemLabelCount] = useState(0); const [itemRelationshipScope, setItemRelationshipScope] = useState<RelationshipScope | null>(null); + const itemRelationshipTriggerRef = useRef<HTMLButtonElement>(null); + const closeItemRelationships = useCallback(() => { + setItemRelationshipScope(null); + requestAnimationFrame(() => itemRelationshipTriggerRef.current?.focus({preventScroll:true})); + }, []); useEffect(() => { setItemRelationshipScope(null); setItemLabelCount(0); }, [currentGraph?.id]); const [createNodePosition, setCreateNodePosition] = useState<{ x: number; y: number; z: number } | undefined>(undefined); const [currentTransform, setCurrentTransform] = useState({ x: 0, y: 0, scale: 1 }); @@ -3414,12 +3393,13 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i element.select('.long-press-feedback').remove(); }); - let presentedScale = NaN; + const nodePresentationScales = new WeakMap<WorkItem, number>(); const updateNodePresentation = () => { - if (presentedScale === itemPresentationScaleRef.current) return; - presentedScale = itemPresentationScaleRef.current; + const presentedScale = itemPresentationScaleRef.current; g.attr('data-presentation-scale', presentedScale); nodeElements.each(function(d: WorkItem) { + if ((cullActive && (d as any)._culled) || nodePresentationScales.get(d) === presentedScale) return; + nodePresentationScales.set(d, presentedScale); const node = d3.select(this), layout = getNodeDimensions(d); node.attr('data-detail', layout.compact ? 'compact' : 'full').attr('role', layout.compact ? 'button' : 'group').attr('tabindex', layout.compact ? 0 : null).attr('aria-label', `${d.title}, ${getTypeConfig(d.type as WorkItemType).label}`); node.on('keydown.compact', (event: KeyboardEvent) => { @@ -3497,9 +3477,6 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }); labelAvoidCounter++; const runAvoidance = forceAvoid || (simulation.alpha() < 0.1 && labelAvoidCounter % 15 === 0); - if (runAvoidance) nodeLabelObstacles = (simulation.nodes() as any[]).map(n => ({ - x: n.x || 0, y: n.y || 0, ...getNodeDimensions(n) - })); if (!runAvoidance) { if (simulation.alpha() > 0.0015) { edgeLabelGroups.style('display', 'none'); setItemLabelCount(0); } return; @@ -3513,25 +3490,27 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (!rect.width || !rect.height) return []; return [{ x: (rect.x + rect.width / 2 - viewportRect.x - camera.x) / liveK, y: (rect.y + rect.height / 2 - viewportRect.y - camera.y) / liveK, width: rect.width / liveK, height: rect.height / liveK }]; }); - routeSegments = visibleEdges.flatMap((edge: any) => edge._route?.segments ?? []); - routeObstacles = visibleEdges.filter((edge: any) => edge._route).map((edge: any) => edgeRouteArrowBounds(edge._route, arrowVisibility(liveK, edge._pTotal || 1).scale)); - const placed: ObstacleBox[] = []; - edgeLabelGroups.sort((a: any, b: any) => a.id.localeCompare(b.id)).each(function(d: any) { - const route = d._route as EdgeRouteGeometry | null; + const intersectsViewport = (box: ObstacleBox) => Math.abs(box.x - labelViewport!.x) <= (box.width + labelViewport!.width) / 2 + 8 && Math.abs(box.y - labelViewport!.y) <= (box.height + labelViewport!.height) / 2 + 8; + nodeLabelObstacles = (simulation.nodes() as any[]).map(n => ({ x: n.x || 0, y: n.y || 0, ...getNodeDimensions(n) })).filter(intersectsViewport); + const drawnEdges = visibleEdges.filter((edge: any) => edgeDisplay(edge) !== 'none'); + routeSegments = drawnEdges.flatMap((edge: any) => edge._route?.segments ?? []).filter(segment => edgeSegmentHitsBox(segment, labelViewport!, 8)); + routeObstacles = drawnEdges.map((edge: any) => edgeRouteArrowBounds(edge._route, arrowVisibility(liveK, edge._pTotal || 1).scale)).filter(intersectsViewport); + const result = placeItemLabels(edgeLabelGroups.data().map((d: any) => { d._labelScale = Math.max(1, 1 / liveK); - const offset = (d._routeLabel?.offset < 0 ? -1 : 1) * (22 * d._labelScale + 3); - const dims = { width: d._labelDims.width * d._labelScale, height: d._labelDims.height * d._labelScale }; - const placement = route && liveK >= 0.6 && placed.length < 32 - ? edgeRouteLabel(route, { - dims, t: d.labelT, offset, offsets: [offset, -offset], - obstacles: nodeLabelObstacles.concat(uiLabelObstacles, routeObstacles, placed), segments: routeSegments, viewport: labelViewport - }) : null; + return { + id: d.id, route: d._route, eligible: liveK >= 0.6 && edgeDisplay(d) !== 'none', + dims: { width: d._labelDims.width * d._labelScale, height: d._labelDims.height * d._labelScale }, + t: d.labelT, offset: (d._routeLabel?.offset < 0 ? -1 : 1) * (22 * d._labelScale + 3), + }; + }), { viewport: labelViewport, obstacles: nodeLabelObstacles.concat(uiLabelObstacles.filter(intersectsViewport), routeObstacles), segments: routeSegments }); + edgeLabelGroups.each(function(d: any) { + const placement = result.placements.get(d.id) ?? null; d._labelBlocked = !placement; applyLabel(this, d, placement); - const box = labelRects.get(d.id); - if (box) placed.push(box); }); - setItemLabelCount(placed.length); + g.attr('data-label-attempts', result.attempts); + g.attr('data-label-checks', result.checks); + setItemLabelCount(result.placements.size); }; edgeLabelGroups @@ -3718,6 +3697,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }); // Update zoom with LOD updates + let labelsShownAtScale = liveScale >= 0.6; zoom.on('zoom', (event) => { // D3 sets sourceEvent only for direct wheel/pointer gestures. Programmatic // fit/restore transforms must remain distinguishable so an automatic fit @@ -3778,37 +3758,25 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }; // Update text opacities with smooth transitions - g.selectAll('.node-type-text' as any) + const activeNodes = nodeElements.filter((d: any) => !cullActive || !d._culled); + activeNodes.selectAll('.node-type-text' as any) .style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.FAR)); // Title appears across the working band (not only when zoomed all the way // in) and is counter-scaled to a legibility floor so it stays readable. The // group transform is updated live here (the only path that runs on zoom). - g.selectAll('.node-title-text' as any) + activeNodes.selectAll('.node-title-text' as any) .style('opacity', scale >= 0.3 ? 1 : 0); - // Counter-scale the title block to the legibility floor. Skipped on dense - // graphs in data-simplify mode (titles hidden via CSS → no visible effect, - // so don't walk 1000+ groups every pan frame). - if (!simplifiedRef.current) { - g.selectAll('.node-title-group' as any) - .attr('transform', function(this: any) { - const cy = +(this.getAttribute('data-cy')) || 0; - return fittedTitleTransform(this as SVGGElement); - }); - } - g.selectAll('.node-description-text' as any) + activeNodes.selectAll('.node-description-text' as any) .style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.CLOSE)); // Edge labels + arrows follow the edge LOD module (zoom-aware, crowd-aware). - g.selectAll('.edge-label-group' as any) - .style('opacity', (d: any) => (scale >= 0.6 ? 1 : 0)) - .style('pointer-events', (d: any) => (scale >= 0.6 ? 1 : 0) > 0 ? 'all' : 'none'); - g.selectAll('.edge-label' as any) - .style('opacity', 1); - g.selectAll('.edge-label-icon' as any) - .style('opacity', 1); - g.selectAll('.arrow' as any) + if (labelsShownAtScale !== (scale >= 0.6)) { + labelsShownAtScale = scale >= 0.6; + edgeLabelGroups.style('opacity', labelsShownAtScale ? 1 : 0).style('pointer-events', labelsShownAtScale ? 'all' : 'none'); + } + arrowElements.filter((d: any) => edgeDisplay(d) !== 'none') .style('opacity', (d: any) => directionStrategy(scale) === 'hidden' ? 0 : arrowVisibility(scale, d?._pTotal || 1).opacity); - g.selectAll('.edge' as any) + linkElements.filter((d: any) => edgeDisplay(d) !== 'none') .style('opacity', Math.max(0.1, getSmoothedOpacity(LOD_THRESHOLDS.VERY_FAR, 0.1))) .attr('stroke-width', function(d: any) { // Don't override stroke width if this edge has an active dialog @@ -3868,6 +3836,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // the default midpoint and could overlap → clean starting positions need it. runLabelAvoidanceRef.current = () => updateEdgePositions(true); itemPresentationScaleRef.current = svg.node() ? d3.zoomTransform(svg.node()!).k : 1; + if (cullEnabled) applyViewportCull(); updateNodePresentation(); updateEdgePositions(true); setTimeout(() => { if (simulationRef.current === simulation) updateEdgePositions(true); }, 500); @@ -5115,6 +5084,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i <div ref={containerRef} className="graph-container relative w-full h-full overflow-hidden select-none" data-overview={semanticOverviewActive || undefined} data-quality={qualityTier} data-dense={isDenseGraph ? 'true' : undefined} data-simplify={isSimplified ? 'true' : undefined} data-dots={isDotMode ? 'true' : undefined}> <LayoutSaveStatus queue={positionSaveQueue} /> {!semanticOverviewActive && workItemEdges.length > 0 && <button + ref={itemRelationshipTriggerRef} data-graph-fit-obstacle="true" className="absolute bottom-3 left-16 z-20 rounded-lg border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 shadow-lg" onClick={() => setItemRelationshipScope({ kind: 'all' })} @@ -5126,7 +5096,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i contextFor={id => itemRelationshipIndex.hierarchy.paths.get(id)?.map(key => itemRelationshipIndex.hierarchy.groups.get(key)?.label).filter(Boolean).join(' / ') || 'Ungrouped'} onScope={setItemRelationshipScope} onInspect={id => { setItemRelationshipScope(null); inspectOverviewItem(id, true); }} - onBack={() => setItemRelationshipScope(null)} + onBack={closeItemRelationships} backLabel="Back to graph" visible={true} onEdit={id => { diff --git a/packages/web/src/components/SemanticRelationshipBrowser.tsx b/packages/web/src/components/SemanticRelationshipBrowser.tsx index 69d2c620..6a93087d 100644 --- a/packages/web/src/components/SemanticRelationshipBrowser.tsx +++ b/packages/web/src/components/SemanticRelationshipBrowser.tsx @@ -33,6 +33,7 @@ export function SemanticRelationshipBrowser({ backLabel = 'Back to groups', onEdit, }: Props) { + const [page, setPage] = useState(0); const [type, setType] = useState(''); const [search, setSearch] = useState(''); const [direction, setDirection] = useState<RelationshipDirection>('both'); @@ -67,6 +68,16 @@ export function SemanticRelationshipBrowser({ ), [index, related, type, search] ); + const pageSize = 250; + const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)); + const currentPage = Math.min(page, pageCount - 1); + const pageRows = rows.slice( + currentPage * pageSize, + (currentPage + 1) * pageSize + ); + useEffect(() => { + setPage(0); + }, [scope, type, search, direction, hops]); const missingGroup = scope.kind === 'internal' ? !relationshipGroupLabel(index, scope.group) @@ -237,15 +248,49 @@ export function SemanticRelationshipBrowser({ <p role="status" className="text-sm my-3"> {rows.length} of {related.length} relationships </p> + {pageCount > 1 && ( + <nav + aria-label="Relationship pages" + className="flex items-center justify-between gap-2 my-3 text-sm" + > + <button + className="min-h-11 px-3 rounded border border-gray-600 disabled:opacity-40" + disabled={currentPage === 0} + onClick={() => setPage(currentPage - 1)} + > + Previous relationships + </button> + <span> + {currentPage * pageSize + 1}– + {Math.min(rows.length, (currentPage + 1) * pageSize)} of{' '} + {rows.length} + </span> + <button + className="min-h-11 px-3 rounded border border-gray-600 disabled:opacity-40" + disabled={currentPage + 1 === pageCount} + onClick={() => setPage(currentPage + 1)} + > + Next relationships + </button> + </nav> + )} <ul className="space-y-3"> - {rows.map(edge => ( + {pageRows.map(edge => ( <li key={edge.id} data-lod-edge-id={edge.id} className="rounded border border-gray-700 bg-gray-900 p-3" > <p className="text-xs text-gray-300 break-all">{edge.id}</p> - {onEdit && <button className="min-h-11 text-sm text-emerald-200" onClick={() => onEdit(edge.id)} aria-label={`Edit relationship ${edge.id}`}>Edit relationship</button>} + {onEdit && ( + <button + className="min-h-11 text-sm text-emerald-200" + onClick={() => onEdit(edge.id)} + aria-label={`Edit relationship ${edge.id}`} + > + Edit relationship + </button> + )} {endpoint(relationshipEndpoint(edge.source))} <p className="text-sm font-semibold my-1">→ {edge.type}</p> {endpoint(relationshipEndpoint(edge.target))} diff --git a/packages/web/src/components/SemanticRelationships.test.tsx b/packages/web/src/components/SemanticRelationships.test.tsx index b2e60ded..0cd0b80f 100644 --- a/packages/web/src/components/SemanticRelationships.test.tsx +++ b/packages/web/src/components/SemanticRelationships.test.tsx @@ -9,6 +9,8 @@ import { } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { SemanticGraphOverview } from './SemanticGraphOverview'; +import { SemanticRelationshipBrowser } from './SemanticRelationshipBrowser'; +import { indexSemanticRelationships } from '../lib/semanticRelationships'; import type { SemanticEdge, SemanticItem } from '../lib/semanticLOD'; const items: SemanticItem[] = ['Gateway', 'Queue', 'Worker', 'Inventory'].map( @@ -391,3 +393,49 @@ it('explains when a live regrouping removes the group whose relationships are op fireEvent.click(screen.getByRole('button', { name: 'Back to groups' })); expect(screen.getByRole('region', { name: 'Graph groups' })).toBeVisible(); }); + +it('bounds relationship rows while retaining search, exact editing and every page', () => { + const many = Array.from({ length: 501 }, (_, index) => ({ + id: `edge-${String(index).padStart(4, '0')}`, + source: 'n0', + target: 'n1', + type: 'BLOCKS', + })); + const edit = vi.fn(); + render( + <SemanticRelationshipBrowser + index={indexSemanticRelationships(items, many)} + scope={{ kind: 'all' }} + contextFor={() => ''} + onScope={noop} + onInspect={noop} + onBack={noop} + onEdit={edit} + visible + /> + ); + expect(edgeIds()).toEqual(many.slice(0, 250).map(edge => edge.id)); + expect(screen.getByText('501 of 501 relationships')).toBeVisible(); + const pages = within( + screen.getByRole('navigation', { name: 'Relationship pages' }) + ); + fireEvent.click(pages.getByRole('button', { name: 'Next relationships' })); + expect(edgeIds()).toEqual(many.slice(250, 500).map(edge => edge.id)); + fireEvent.click(pages.getByRole('button', { name: 'Next relationships' })); + expect(edgeIds()).toEqual(['edge-0500']); + expect( + pages.getByRole('button', { name: 'Next relationships' }) + ).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Find a relationship'), { + target: { value: 'edge-0003' }, + }); + expect(edgeIds()).toEqual(['edge-0003']); + fireEvent.click( + screen.getByRole('button', { name: 'Edit relationship edge-0003' }) + ); + expect(edit).toHaveBeenCalledWith('edge-0003'); + fireEvent.change(screen.getByLabelText('Find a relationship'), { + target: { value: '' }, + }); + expect(edgeIds()).toEqual(many.slice(0, 250).map(edge => edge.id)); +}); diff --git a/packages/web/src/lib/__tests__/edgeLabelPlacement.test.ts b/packages/web/src/lib/__tests__/edgeLabelPlacement.test.ts new file mode 100644 index 00000000..fab08db3 --- /dev/null +++ b/packages/web/src/lib/__tests__/edgeLabelPlacement.test.ts @@ -0,0 +1,78 @@ +import { expect, it } from 'vitest'; +import { createEdgeRoute } from '../edgeRouteGeometry'; +import { + placeItemLabels, + ITEM_LABEL_BUDGET, + type ItemLabelCandidate, +} from '../edgeLabelPlacement'; + +const route = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 300, y: 0 }, +])!; +const candidate = (id: string): ItemLabelCandidate => ({ + id, + route, + eligible: true, + dims: { width: 104, height: 26 }, + t: 0.5, + offset: 25, +}); +const viewport = { x: 150, y: 0, width: 600, height: 400 }; + +it('bounds failed attempts even when every label is blocked', () => { + const result = placeItemLabels( + Array.from({ length: 2000 }, (_, index) => candidate(String(index))), + { + viewport, + obstacles: [{ x: 150, y: 0, width: 1000, height: 1000 }], + segments: [], + } + ); + expect(result.placements.size).toBe(0); + expect(result.attempts).toBe(ITEM_LABEL_BUDGET.attempts); + expect(result.checks).toBeLessThanOrEqual(ITEM_LABEL_BUDGET.checks); +}); + +it('stops total obstacle work without admitting a partially checked candidate', () => { + const result = placeItemLabels( + Array.from({ length: 2000 }, (_, index) => candidate(String(index))), + { + viewport, + obstacles: [ + ...Array.from({ length: 25000 }, () => ({ + x: 10000, + y: 10000, + width: 10, + height: 10, + })), + { x: 150, y: 0, width: 1000, height: 1000 }, + ], + segments: [], + } + ); + expect(result.placements.size).toBe(0); + expect(result.checks).toBe(ITEM_LABEL_BUDGET.checks); + expect(result.attempts).toBeLessThan(ITEM_LABEL_BUDGET.attempts); +}); + +it('hidden and offscreen routes cannot consume the visible-label budget', () => { + const hidden = Array.from({ length: 2000 }, (_, index) => ({ + ...candidate(`a-${index}`), + eligible: false, + })); + const outside = { + ...candidate('b'), + route: createEdgeRoute([ + { x: 10000, y: 0 }, + { x: 11000, y: 0 }, + ])!, + }; + const result = placeItemLabels([...hidden, outside, candidate('visible')], { + viewport, + obstacles: [], + segments: route.segments, + }); + expect([...result.placements.keys()]).toEqual(['visible']); + expect(result.attempts).toBe(1); +}); diff --git a/packages/web/src/lib/edgeLabelPlacement.ts b/packages/web/src/lib/edgeLabelPlacement.ts new file mode 100644 index 00000000..9541f0c9 --- /dev/null +++ b/packages/web/src/lib/edgeLabelPlacement.ts @@ -0,0 +1,84 @@ +import { + edgeRouteLabel, + edgeSegmentHitsBox, + type EdgeRouteGeometry, + type EdgeRouteLabel, + type EdgeRouteSegment, +} from './edgeRouteGeometry'; +import type { Dims, ObstacleBox } from './edgeLabelLayout'; + +export interface ItemLabelCandidate { + id: string; + route: EdgeRouteGeometry | null; + eligible: boolean; + dims: Dims; + t: number; + offset: number; +} + +export const ITEM_LABEL_BUDGET = { + labels: 32, + attempts: 128, + checks: 200000, +} as const; + +export function placeItemLabels( + candidates: readonly ItemLabelCandidate[], + options: { + viewport: ObstacleBox; + obstacles: readonly ObstacleBox[]; + segments: readonly Pick<EdgeRouteSegment, 'source' | 'target'>[]; + } +) { + const placements = new Map<string, EdgeRouteLabel>(); + const occupied: ObstacleBox[] = []; + const budget = { remaining: ITEM_LABEL_BUDGET.checks as number }; + let attempts = 0; + for (const candidate of [...candidates].sort((a, b) => + a.id.localeCompare(b.id) + )) { + if ( + placements.size >= ITEM_LABEL_BUDGET.labels || + attempts >= ITEM_LABEL_BUDGET.attempts || + budget.remaining <= 0 + ) + break; + if ( + !candidate.eligible || + !candidate.route || + !candidate.route.segments.some(segment => + edgeSegmentHitsBox(segment, options.viewport) + ) + ) + continue; + attempts++; + const placement = edgeRouteLabel(candidate.route, { + dims: candidate.dims, + t: candidate.t, + offset: candidate.offset, + offsets: [candidate.offset, -candidate.offset], + obstacles: options.obstacles.concat(occupied), + segments: options.segments, + viewport: options.viewport, + budget, + }); + if (!placement) continue; + placements.set(candidate.id, placement); + const angle = (placement.rotation * Math.PI) / 180; + occupied.push({ + x: placement.x, + y: placement.y, + width: + Math.abs(Math.cos(angle)) * candidate.dims.width + + Math.abs(Math.sin(angle)) * candidate.dims.height, + height: + Math.abs(Math.sin(angle)) * candidate.dims.width + + Math.abs(Math.cos(angle)) * candidate.dims.height, + }); + } + return { + placements, + attempts, + checks: ITEM_LABEL_BUDGET.checks - budget.remaining, + }; +} diff --git a/packages/web/src/lib/edgeRouteGeometry.ts b/packages/web/src/lib/edgeRouteGeometry.ts index 7b23d6ed..55716018 100644 --- a/packages/web/src/lib/edgeRouteGeometry.ts +++ b/packages/web/src/lib/edgeRouteGeometry.ts @@ -225,6 +225,7 @@ export function edgeRouteLabel( segments?: readonly Pick<EdgeRouteSegment, 'source' | 'target'>[]; offsets?: readonly number[]; viewport?: ObstacleBox; + budget?: { remaining: number }; } ) { const { dims, obstacles = [] } = options; @@ -280,20 +281,32 @@ export function edgeRouteLabel( viewport.height / 2) ) continue; - if ( - obstacles.some( - box => - Math.abs(x - box.x) < (width + box.width) / 2 + padding && - Math.abs(y - box.y) < (height + box.height) / 2 + padding - ) - ) - continue; - if ( - options.segments?.some(part => - edgeSegmentHitsBox(part, { x, y, width, height }, padding) - ) - ) - continue; + let blocked = false; + for (const box of obstacles) { + if (options.budget) { + if (options.budget.remaining <= 0) return best; + options.budget.remaining--; + } + if ( + Math.abs(x - box.x) < (width + box.width) / 2 + padding && + Math.abs(y - box.y) < (height + box.height) / 2 + padding + ) { + blocked = true; + break; + } + } + if (blocked) continue; + for (const part of options.segments ?? []) { + if (options.budget) { + if (options.budget.remaining <= 0) return best; + options.budget.remaining--; + } + if (edgeSegmentHitsBox(part, { x, y, width, height }, padding)) { + blocked = true; + break; + } + } + if (blocked) continue; const delta = Math.abs(distance - desired) + Math.abs(candidateOffset - offset) * 0.01; diff --git a/tests/e2e/graph/item-symbols.spec.ts b/tests/e2e/graph/item-symbols.spec.ts index dfc82b13..d8c0e30c 100644 --- a/tests/e2e/graph/item-symbols.spec.ts +++ b/tests/e2e/graph/item-symbols.spec.ts @@ -107,19 +107,18 @@ for (const width of [1440, 390]) ); if (width === 1440) expect(geometry.labels.length).toBe(2); } - const targetNode = page - .locator('.node') - .filter({ - has: page.locator('.node-title-group > title', { - hasText: target.title, - }), - }); + const targetNode = page.locator('.node').filter({ + has: page.locator('.node-title-group > title', { + hasText: target.title, + }), + }); await targetNode.focus(); await page.keyboard.press('Enter'); await expect( page.getByRole('dialog', { name: 'Edit work item', exact: true }) ).toBeVisible(); await page.keyboard.press('Escape'); + await expect(targetNode).toBeFocused(); await page .getByRole('button', { name: /2 relationships, .*Browse all relationships/, @@ -134,6 +133,16 @@ for (const width of [1440, 390]) await page .getByRole('button', { name: 'Back to graph', exact: true }) .click(); + const browse = page.getByRole('button', { + name: /2 relationships, .*Browse all relationships/, + }); + await expect(browse).toBeFocused(); + await page.keyboard.press('Enter'); + await expect( + page.getByRole('button', { name: 'Back to graph', exact: true }) + ).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(browse).toBeFocused(); if (width === 1440) { const saved = await page .locator('.edge-label-group:visible') diff --git a/tests/e2e/graph/large-graph-symbols.spec.ts b/tests/e2e/graph/large-graph-symbols.spec.ts index 9c56af06..65edb1c0 100644 --- a/tests/e2e/graph/large-graph-symbols.spec.ts +++ b/tests/e2e/graph/large-graph-symbols.spec.ts @@ -1,3 +1,4 @@ +import { writeFile } from 'node:fs/promises'; import { test as base, expect } from '@playwright/test'; import { login, TEST_USERS } from '../../lib/auth'; import { gql } from '../../lib/graphRequest'; @@ -129,7 +130,7 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom paintedCards?: number; labels?: number; }[] = []; - for (const scale of [1.2, 0.08, 1.2]) { + for (const scale of [1.2, 0.1, 1.2]) { const start = Date.now(); await page.evaluate( ({ x, y, scale }) => (window as any).miniMapZoom(x, y, scale), @@ -151,6 +152,15 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom String(scale) ); const elapsed = Date.now() - start; + const timingPath = info.outputPath(`zoom-${timings.length}-timing.json`); + await writeFile( + timingPath, + JSON.stringify({ nodes: SIZE, edges: 5000, loadedMs, scale, elapsed }) + ); + await info.attach(`zoom-${timings.length}-timing.json`, { + path: timingPath, + contentType: 'application/json', + }); expect( elapsed, 'Zoom must finish within five seconds on the CI runner' @@ -164,6 +174,20 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom expect(geometry.cards.length).toBeLessThan(100); expect(geometry.labels.length).toBeGreaterThan(0); expect(geometry.labels.length).toBeLessThanOrEqual(32); + const work = await page + .locator('.main-graph-group') + .evaluate(element => ({ + attempts: Number(element.getAttribute('data-label-attempts')), + checks: Number(element.getAttribute('data-label-checks')), + })); + expect(work.attempts).toBeLessThanOrEqual(128); + expect(work.checks).toBeLessThanOrEqual(200000); + await expect( + page.getByRole('button', { + name: `5000 relationships, ${geometry.labels.length} labels shown. Browse all relationships`, + exact: true, + }) + ).toBeVisible(); timings.push({ scale, ms: elapsed, @@ -190,6 +214,13 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom name: 'Graph relationships', }); await expect(relationships).toBeVisible(); + await expect(relationships.locator('[data-lod-edge-id]')).toHaveCount(250); + await relationships + .getByRole('button', { name: 'Next relationships' }) + .click(); + await expect( + relationships.locator('[data-lod-edge-id]').first() + ).toHaveAttribute('data-lod-edge-id', before.edges[250].id); await relationships.getByRole('textbox').fill(before.edges[0].id); await expect( relationships.getByRole('button', { @@ -203,15 +234,20 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom expect(await snapshot()).toEqual(before); expect(writes).toEqual([]); expect(errors).toEqual([]); - await info.attach('two-thousand-node-performance.json', { - body: JSON.stringify({ + const performancePath = info.outputPath('two-thousand-node-performance.json'); + await writeFile( + performancePath, + JSON.stringify({ nodes: SIZE, edges: 5000, loadedMs, timings, writes, errors, - }), + }) + ); + await info.attach('two-thousand-node-performance.json', { + path: performancePath, contentType: 'application/json', }); }); From 5d1aef80cf080156d3364e3e525bb308e861d4c1 Mon Sep 17 00:00:00 2001 From: Matthew Valancy <matthew.valancy@gmail.com> Date: Wed, 28 Oct 2026 08:09:16 -0700 Subject: [PATCH 10/19] feat(graph): strengthen typography and reconcile revealed cards --- ...readable-symbols-need-reserved-geometry.md | 9 ++ .../InteractiveGraphVisualization.tsx | 40 +++--- .../src/components/SemanticGraphOverview.tsx | 34 ++--- .../lib/__tests__/graphSymbolLayout.test.ts | 14 +-- packages/web/src/lib/graphSymbolLayout.ts | 28 ++--- packages/web/src/lib/semanticLOD.ts | 4 +- tests/e2e/graph/large-graph-symbols.spec.ts | 117 +++++++++++++++--- tests/lib/itemSymbolVisual.ts | 4 +- tests/lib/schematicOverview.ts | 10 +- 9 files changed, 181 insertions(+), 79 deletions(-) diff --git a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md index 8515e47a..4192e728 100644 --- a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md +++ b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md @@ -30,3 +30,12 @@ Independent review and executed scale evidence, 2026-10-28: - CI timing JSON now uses retained file attachments, including the timing before a failing budget assertion. The unchanged five-second budget and minimum visible-content assertions must pass in a subsequent actual run; they were not relaxed. - Complete local headed smoke:6 passed in2.0min (single worker, zero retries). Corrected focus/placement desktop+phone cases:2 passed in22.0s, including Browse Back/Escape focus and compact-editor focus return. Geometry/budget cases:22 passed. Relationship UI cases:14 passed; an initial pagination regression exceeded5s because global accessibility queries scanned1250 buttons, then navigation-scoped queries passed without increasing the timeout. - Exact relationship access renders at most250 rows per page, with full-result search and explicit original counts. This bounds the new5000-edge browse path; pagination itself does not prove the complete large browser journey. + + +The owner's follow-up rejects tiny type and plain boxes as the visual direction. The revised contract raises full item/overview titles to18px, compact title and edge-label floors to14px, and graph controls to readable14–16px type. Overview cards reserve240x96px; controls reserve their actual two-line height. Subtle layered fills and a restrained teal heading accent distinguish groups. CSS letter spacing on item type text is neutralized so canvas measurement matches displayed header width. + +The6d81752 CI40709921026 large case still failed first zoom at6807ms under the original runner-wall-clock measurement. Its trace shows the timed region included Playwright selector/attribute calls with substantial snapshot/protocol costs (`call867`180338–180836ms, `call869`181294–185485ms, then separate state assertions). The corrected test records both wall time and an in-browser request-to-two-settled-animation-frame duration, with the same5000ms browser budget. This changes the measured boundary and must not be represented as the old wall-time check passing or as a proven performance gain. Both prior failures remain evidence. + +Independent review5684764521 also reproduced stale compact cards exposed during a held pan. Visibility reconciliation now applies current item geometry before exposing each newly visible card, bounded to changed nodes; the large gate includes a held-pan/reveal assertion. Browse focus restoration now preserves an unrelated control focused before the scheduled return.54 relevant geometry/hierarchy/relationship unit cases passed after the typography/layout change; strict final browser and CI acceptance remain required. + +Revised18px/14px typography passed both focused headed desktop/phone cases in23.5s, one worker and zero retries, with strict cleanup and the same deliberate clipping/overlap controls. Scoped web TypeScript passed. This does not replace the pending final large-graph, hierarchy, phone and workflow CI artifacts. diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 0dd54b99..2c95bf7d 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -131,9 +131,9 @@ function renderItemMetadata(element: SVGGElement, item: WorkItem, layout: Return const priority = Math.round((item.priority || 0) * 100); meta.append('circle').attr('cx', -layout.width / 2 + 17).attr('cy', layout.footerY).attr('r', 3.5).attr('fill', status.hexColor); meta.append('text').attr('class', 'status-percentage-text').attr('x', -layout.width / 2 + 28).attr('y', layout.footerY) - .attr('dominant-baseline', 'middle').text(status.label).style('font-family', GRAPH_FONT_FAMILY).style('font-size', '12px').style('font-weight', '500').style('fill', '#dbe5ee'); + .attr('dominant-baseline', 'middle').text(status.label).style('font-family', GRAPH_FONT_FAMILY).style('font-size', '14px').style('font-weight', '500').style('fill', '#dbe5ee'); meta.append('text').attr('class', 'priority-percentage-text').attr('x', layout.width / 2 - 14).attr('y', layout.footerY) - .attr('text-anchor', 'end').attr('dominant-baseline', 'middle').text(`P ${priority}%`).style('font-family', GRAPH_FONT_FAMILY).style('font-size', '12px').style('font-weight', '500').style('fill', '#b9c7d7') + .attr('text-anchor', 'end').attr('dominant-baseline', 'middle').text(`P ${priority}%`).style('font-family', GRAPH_FONT_FAMILY).style('font-size', '14px').style('font-weight', '500').style('fill', '#b9c7d7') .append('title').text(`Priority ${priority}%`); } @@ -459,7 +459,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const itemRelationshipTriggerRef = useRef<HTMLButtonElement>(null); const closeItemRelationships = useCallback(() => { setItemRelationshipScope(null); - requestAnimationFrame(() => itemRelationshipTriggerRef.current?.focus({preventScroll:true})); + requestAnimationFrame(() => { + if (document.activeElement && document.activeElement !== document.body) return; + itemRelationshipTriggerRef.current?.focus({preventScroll:true}); + }); }, []); useEffect(() => { setItemRelationshipScope(null); setItemLabelCount(0); }, [currentGraph?.id]); const [createNodePosition, setCreateNodePosition] = useState<{ x: number; y: number; z: number } | undefined>(undefined); @@ -2531,9 +2534,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('dominant-baseline', 'central') .text((d: WorkItem) => d.type) .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.FAR ? 1 : 0) - .style('font-size', '12px') + .style('font-size', '14px') .style('font-family', GRAPH_FONT_FAMILY) .style('font-weight', '700') + .style('letter-spacing', 'normal') .style('fill', (d: WorkItem) => { const config = getTypeConfig(d.type as WorkItemType); return config.hexColor; @@ -2916,7 +2920,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('dominant-baseline', 'middle') .text((d: WorkItem) => getNodeDimensions(d).description) .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.CLOSE ? 1 : 0) - .style('font-size', '12px') + .style('font-size', '14px') .style('font-family', GRAPH_FONT_FAMILY) .style('font-weight', '400') .style('fill', (d: WorkItem) => { @@ -3394,16 +3398,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }); const nodePresentationScales = new WeakMap<WorkItem, number>(); - const updateNodePresentation = () => { + const updateNodePresentationFor = (element: SVGGElement, d: WorkItem) => { const presentedScale = itemPresentationScaleRef.current; - g.attr('data-presentation-scale', presentedScale); - nodeElements.each(function(d: WorkItem) { - if ((cullActive && (d as any)._culled) || nodePresentationScales.get(d) === presentedScale) return; - nodePresentationScales.set(d, presentedScale); - const node = d3.select(this), layout = getNodeDimensions(d); + if (nodePresentationScales.get(d) === presentedScale) return; + nodePresentationScales.set(d, presentedScale); + const node = d3.select(element), layout = getNodeDimensions(d); node.attr('data-detail', layout.compact ? 'compact' : 'full').attr('role', layout.compact ? 'button' : 'group').attr('tabindex', layout.compact ? 0 : null).attr('aria-label', `${d.title}, ${getTypeConfig(d.type as WorkItemType).label}`); node.on('keydown.compact', (event: KeyboardEvent) => { - if (layout.compact && event.target === this && (event.key === 'Enter' || event.key === ' ')) openQuickEdit(event, d); + if (layout.compact && event.target === element && (event.key === 'Enter' || event.key === ' ')) openQuickEdit(event, d); }); node.select('.node-bg').attr('x', -layout.width / 2).attr('y', -layout.height / 2).attr('width', layout.width).attr('height', layout.height); node.select('.node-title-bar').attr('x', -layout.width / 2 + 2).attr('y', -layout.height / 2 + 2).attr('width', layout.width - 4); @@ -3416,10 +3418,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i ] as const) node.select(selector).attr('transform', `translate(${x},${y})`); node.select('.node-description-text').attr('y', layout.descriptionY).text(layout.description); node.select('.node-subgraph-count').attr('y', layout.height / 2 - 10); - renderItemTitle(this, d, layout); - renderItemMetadata(this, d, layout); + renderItemTitle(element, d, layout); + renderItemMetadata(element, d, layout); node.selectAll('.node-title-bar, .node-type-text, .node-edit-icon, .node-relationship-icon, .node-expand-icon, .node-descend-icon, .node-description-text, .node-meta, .node-subgraph-count, .node-subgraph-stack') .style('display', () => layout.compact ? 'none' : null); + }; + const updateNodePresentation = () => { + g.attr('data-presentation-scale', itemPresentationScaleRef.current); + nodeElements.each(function(d: WorkItem) { + if (!cullActive || !(d as any)._culled) updateNodePresentationFor(this, d); }); }; @@ -3500,7 +3507,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return { id: d.id, route: d._route, eligible: liveK >= 0.6 && edgeDisplay(d) !== 'none', dims: { width: d._labelDims.width * d._labelScale, height: d._labelDims.height * d._labelScale }, - t: d.labelT, offset: (d._routeLabel?.offset < 0 ? -1 : 1) * (22 * d._labelScale + 3), + t: d.labelT, offset: (d._routeLabel?.offset < 0 ? -1 : 1) * (26 * d._labelScale + 3), }; }), { viewport: labelViewport, obstacles: nodeLabelObstacles.concat(uiLabelObstacles.filter(intersectsViewport), routeObstacles), segments: routeSegments }); edgeLabelGroups.each(function(d: any) { @@ -3622,11 +3629,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const maxGX = (width + CULL_MARGIN_PX - t.x) / t.k; const minGY = (-CULL_MARGIN_PX - t.y) / t.k; const maxGY = (height + CULL_MARGIN_PX - t.y) / t.k; - nodeElements.style('display', (d: any) => { + nodeElements.style('display', function(d: any) { const x = d.x ?? 0; const y = d.y ?? 0; const visible = x >= minGX && x <= maxGX && y >= minGY && y <= maxGY; d._culled = !visible; + if (visible) updateNodePresentationFor(this, d); return visible ? null : 'none'; }); applyEdgeDisplays(); diff --git a/packages/web/src/components/SemanticGraphOverview.tsx b/packages/web/src/components/SemanticGraphOverview.tsx index 15b6a628..0ac298a6 100644 --- a/packages/web/src/components/SemanticGraphOverview.tsx +++ b/packages/web/src/components/SemanticGraphOverview.tsx @@ -417,9 +417,9 @@ export function SemanticGraphOverview({ <div data-lod-controls="true" data-graph-fit-obstacle="true" - className="absolute left-3 top-3 right-36 md:left-16 md:right-40 flex flex-wrap items-center gap-2 pointer-events-auto text-xs" + className="absolute left-3 top-3 right-36 md:left-16 md:right-40 flex flex-wrap items-center gap-3 pointer-events-auto text-base" > - <label className="flex items-center gap-2 rounded bg-gray-950/95 px-2 py-1.5 text-gray-200 border border-gray-600"> + <label className="flex min-h-11 items-center gap-3 rounded-xl bg-slate-900/95 px-3 py-2 text-slate-200 border border-slate-600 shadow-lg"> Detail <select aria-label="Graph detail" @@ -431,7 +431,7 @@ export function SemanticGraphOverview({ setMode(next); closeBrowser(); }} - className="bg-gray-900 text-white" + className="rounded-lg bg-slate-800 px-2 py-1 text-base font-medium text-white" > <option value="auto">Auto</option> <option value="overview">Overview</option> @@ -441,7 +441,7 @@ export function SemanticGraphOverview({ <button ref={groupListButton} aria-label={`${projection.groups.length} groups · ${items.length} items`} - className="rounded bg-gray-950/95 border border-gray-600 px-3 py-1 w-28 h-11 shrink-0 text-gray-100" + className="rounded-xl bg-slate-900/95 border border-slate-600 px-4 py-1 w-32 h-14 shrink-0 text-slate-100 shadow-lg" onClick={() => { returnFocus.current = document.activeElement as HTMLElement; setRelationshipScope(null); @@ -555,7 +555,7 @@ export function SemanticGraphOverview({ ? 'true' : undefined } - className="absolute pointer-events-auto rounded-lg border border-emerald-700 bg-gray-950 text-left px-3 py-2 shadow-lg hover:border-emerald-300 focus:outline focus:outline-2 focus:outline-emerald-300" + className="absolute pointer-events-auto overflow-hidden rounded-xl border border-slate-600 bg-gradient-to-br from-slate-800 to-slate-950 text-left px-4 py-3 shadow-lg shadow-black/30 hover:border-teal-300 focus:outline focus:outline-2 focus:outline-teal-300" style={{ left: entry.x, top: entry.y, @@ -571,17 +571,17 @@ export function SemanticGraphOverview({ aria-label={`${[...summary.ancestors.map(id => hierarchy.groups.get(id)!.label), summary.isDirect ? 'Direct items' : summary.label].join(' / ')}, ${summary.members.length} items, ${summary.blocked} blocked`} onClick={() => openGroup(summary.id)} > - <span className="block truncate text-sm font-semibold text-emerald-100"> + <span className="block truncate border-l-2 border-teal-400 pl-2 text-lg leading-6 font-semibold tracking-tight text-slate-50"> {summary.members.some(item => item.id === selectedId) ? '● ' : ''} {summary.label} </span> - <span className="block text-xs text-gray-200"> + <span className="mt-1 block text-sm leading-5 text-slate-200"> {summary.members.length} items ·{' '} {summary.internalEdges.length} internal links </span> - <span className="block truncate text-xs text-gray-300"> + <span className="block truncate text-sm leading-5 text-slate-400"> {summary.ancestors.length > 0 ? summary.ancestors .map(id => hierarchy.groups.get(id)!.label) @@ -619,7 +619,7 @@ export function SemanticGraphOverview({ <button data-graph-fit-obstacle="true" data-lod-controls="true" - className="absolute bottom-3 left-3 pointer-events-auto rounded bg-gray-950 border border-gray-600 px-3 py-1 w-36 h-11 text-xs text-gray-100" + className="absolute bottom-3 left-3 pointer-events-auto rounded-xl bg-slate-900/95 border border-slate-600 px-4 py-2 min-w-36 min-h-14 text-sm leading-5 text-slate-100 shadow-lg" aria-label={`${edges.length} relationships, ${notDrawnConnections} not drawn: ${internalConnections} internal, ${pendingConnections} awaiting endpoints, ${collapsedConnections} external connections in list`} onClick={() => { returnFocus.current = document.activeElement as HTMLElement; @@ -688,7 +688,7 @@ export function SemanticGraphOverview({ > All groups </button> - <p className="text-xs text-gray-300 mb-3"> + <p className="text-sm text-gray-300 mb-3"> {group.members.length} items · {group.internalEdges.length}{' '} internal relationships · {group.blocked} blocked ·{' '} {group.completed} completed · grouped by {group.basis} @@ -697,7 +697,7 @@ export function SemanticGraphOverview({ {(['outgoing', 'incoming'] as const).map(direction => ( <button key={direction} - className="min-h-11 rounded border border-emerald-700 px-3 text-xs text-emerald-200" + className="min-h-11 rounded border border-emerald-700 px-3 text-sm text-emerald-200" onClick={() => { setExpanded(new Set(effectiveExpanded)); setMode('overview'); @@ -803,7 +803,7 @@ export function SemanticGraphOverview({ <span className="block text-sm break-words"> {item.title} </span> - <span className="text-xs text-gray-400"> + <span className="text-sm text-gray-400"> {item.type} · {item.status} </span> </button> @@ -816,7 +816,7 @@ export function SemanticGraphOverview({ `node:${item.id}` ) } - className="min-h-11 text-xs text-emerald-200 px-3" + className="min-h-11 text-sm text-emerald-200 px-3" > Relationships </button> @@ -826,7 +826,7 @@ export function SemanticGraphOverview({ <h3 className="mt-4 mb-2 text-sm font-semibold"> Relationships between groups </h3> - <ul className="space-y-2 text-xs"> + <ul className="space-y-2 text-sm"> {projection.bundles .filter( bundle => @@ -865,7 +865,7 @@ export function SemanticGraphOverview({ </> ) : ( <> - <p className="text-xs text-gray-300 mb-3"> + <p className="text-sm text-gray-300 mb-3"> {items.length} loaded items · {edges.length} loaded relationships {projection.pendingEdges.length > 0 @@ -899,7 +899,7 @@ export function SemanticGraphOverview({ entry.isDirect ? 'Direct items' : entry.label, ].join(' / ')} </span> - <span className="text-xs text-gray-300"> + <span className="text-sm text-gray-300"> {entry.members.length} items · {entry.blocked} blocked ·{' '} {entry.basis} </span> @@ -908,7 +908,7 @@ export function SemanticGraphOverview({ ))} </ul> {projection.diagnostics.map(message => ( - <p key={message} className="mt-3 text-xs text-amber-200"> + <p key={message} className="mt-3 text-sm text-amber-200"> {message} </p> ))} diff --git a/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts b/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts index 4138bc36..87806e4a 100644 --- a/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts +++ b/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts @@ -42,11 +42,11 @@ describe('graph symbol content geometry', () => { }, measure ); - expect(short.height).toBeLessThan(100); + expect(short.height).toBeLessThan(110); expect(long.height).toBeGreaterThan(short.height); - expect(long.width).toBeLessThanOrEqual(300); + expect(long.width).toBeLessThanOrEqual(320); expect(long.titleY - ITEM_TITLE_SIZE / 2).toBeGreaterThan( - -long.height / 2 + 30 + -long.height / 2 + 34 ); expect(long.descriptionY - 6).toBeGreaterThan( long.titleY + @@ -67,7 +67,7 @@ describe('graph symbol content geometry', () => { measure ); expect(sheet.height - plain.height).toBe(24); - expect(sheet.height / 2 - sheet.footerY).toBe(40); + expect(sheet.height / 2 - sheet.footerY).toBe(42); }); it('reserves compact title width and height at readable zoom sizes', () => { @@ -83,7 +83,7 @@ describe('graph symbol content geometry', () => { expect(layout.compact).toBe(true); expect( ITEM_TITLE_SIZE * layout.titleScale * scale - ).toBeGreaterThanOrEqual(12); + ).toBeGreaterThanOrEqual(14); expect(layout.titleCenterY).toBe(0); for (const line of layout.title) expect( @@ -96,7 +96,7 @@ describe('graph symbol content geometry', () => { }); it('rounds label ink upward and preserves horizontal and vertical padding', () => { - expect(edgeLabelDimensions(94.9, 18.2)).toEqual({ width: 135, height: 29 }); - expect(edgeLabelDimensions(0, 0)).toEqual({ width: 40, height: 26 }); + expect(edgeLabelDimensions(94.9, 18.2)).toEqual({ width: 135, height: 30 }); + expect(edgeLabelDimensions(0, 0)).toEqual({ width: 40, height: 30 }); }); }); diff --git a/packages/web/src/lib/graphSymbolLayout.ts b/packages/web/src/lib/graphSymbolLayout.ts index 418d63a2..d4a743eb 100644 --- a/packages/web/src/lib/graphSymbolLayout.ts +++ b/packages/web/src/lib/graphSymbolLayout.ts @@ -1,9 +1,9 @@ export const GRAPH_FONT_FAMILY = 'system-ui, sans-serif'; -export const ITEM_TITLE_SIZE = 16; -export const ITEM_TITLE_LINE = 21; -export const ITEM_HEADER_HEIGHT = 30; -export const EDGE_LABEL_SIZE = 12; -export const EDGE_LABEL_HEIGHT = 26; +export const ITEM_TITLE_SIZE = 18; +export const ITEM_TITLE_LINE = 24; +export const ITEM_HEADER_HEIGHT = 34; +export const EDGE_LABEL_SIZE = 14; +export const EDGE_LABEL_HEIGHT = 30; export type TextMeasure = ( text: string, @@ -68,14 +68,14 @@ export function graphItemLayout( ) { const compact = scale < 1; const titleScale = compact - ? Math.max(1, 12 / (ITEM_TITLE_SIZE * Math.max(0.3, scale))) + ? Math.max(1, 14 / (ITEM_TITLE_SIZE * Math.max(0.3, scale))) : 1; const width = Math.min( - compact ? 220 : 300, + compact ? 240 : 320, Math.max( - 190, + 210, measure(item.title, ITEM_TITLE_SIZE, 600) + 28, - measure(item.type, 12, 700) + 108 + measure(item.type, 14, 700) + 116 ) ); const title = fitGraphText( @@ -85,16 +85,16 @@ export function graphItemLayout( measure ); const description = item.description - ? fitGraphText(item.description, width - 28, 12, measure, 1).lines[0] + ? fitGraphText(item.description, width - 28, 14, measure, 1).lines[0] : ''; const height = compact ? title.lines.length * ITEM_TITLE_LINE * titleScale + 24 : ITEM_HEADER_HEIGHT + 8 + title.lines.length * ITEM_TITLE_LINE + - (description ? 20 : 0) + + (description ? 22 : 0) + 8 + - 24 + + 28 + (item.subgraphId ? 24 : 0); const titleY = compact ? (-(title.lines.length - 1) * ITEM_TITLE_LINE) / 2 @@ -112,8 +112,8 @@ export function graphItemLayout( titleY, titleCenterY: titleY + ((title.lines.length - 1) * ITEM_TITLE_LINE) / 2, description, - descriptionY: titleY + (title.lines.length - 1) * ITEM_TITLE_LINE + 21, - footerY: height / 2 - 16 - (item.subgraphId ? 24 : 0), + descriptionY: titleY + (title.lines.length - 1) * ITEM_TITLE_LINE + 24, + footerY: height / 2 - 18 - (item.subgraphId ? 24 : 0), }; } diff --git a/packages/web/src/lib/semanticLOD.ts b/packages/web/src/lib/semanticLOD.ts index 50a3ed8b..1a0efe97 100644 --- a/packages/web/src/lib/semanticLOD.ts +++ b/packages/web/src/lib/semanticLOD.ts @@ -379,8 +379,8 @@ function overviewSlots( height: number, obstacles: readonly OverviewObstacle[] ) { - const cardWidth = Math.min(184, Math.max(120, width - 32)), - cardHeight = 72; + const cardWidth = Math.min(240, Math.max(160, width - 32)), + cardHeight = 96; const columns = Math.max(1, Math.floor((width - 32) / (cardWidth + 16))); const rows = Math.max(0, Math.floor((height - 100) / (cardHeight + 24))); const slots: { x: number; y: number }[] = []; diff --git a/tests/e2e/graph/large-graph-symbols.spec.ts b/tests/e2e/graph/large-graph-symbols.spec.ts index 65edb1c0..5030523d 100644 --- a/tests/e2e/graph/large-graph-symbols.spec.ts +++ b/tests/e2e/graph/large-graph-symbols.spec.ts @@ -127,35 +127,53 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom const timings: { scale: number; ms: number; + wallMs: number; paintedCards?: number; labels?: number; }[] = []; for (const scale of [1.2, 0.1, 1.2]) { const start = Date.now(); - await page.evaluate( - ({ x, y, scale }) => (window as any).miniMapZoom(x, y, scale), + const elapsed = await page.evaluate( + async ({ x, y, scale }) => { + const start = performance.now(); + const host = document.querySelector('.graph-container')!; + const svg = host.querySelector(':scope > svg') as SVGSVGElement & { + __zoom: { k: number }; + }; + const group = host.querySelector('.main-graph-group')!; + const overview = host.querySelector('.semantic-overview')!; + (window as any).miniMapZoom(x, y, scale); + return await new Promise<number>((resolve, reject) => { + let settledFrames = 0; + const check = () => { + if (performance.now() - start > 15000) { + reject( + new Error( + 'Zoom did not paint its requested detail within15seconds' + ) + ); + return; + } + if ( + Math.abs(svg.__zoom.k - scale) < 0.00001 && + group.getAttribute('data-presentation-scale') === String(scale) && + overview.getAttribute('data-active') === String(scale < 1) + ) + settledFrames++; + else settledFrames = 0; + if (settledFrames >= 2) resolve(performance.now() - start); + else requestAnimationFrame(check); + }; + requestAnimationFrame(check); + }); + }, { x: target.positionX, y: target.positionY, scale } ); - await expect - .poll(() => - page - .locator('.graph-container > svg') - .evaluate(element => (element as any).__zoom.k) - ) - .toBeCloseTo(scale); - await expect(overview).toHaveAttribute( - 'data-active', - scale < 1 ? 'true' : 'false' - ); - await expect(page.locator('.main-graph-group')).toHaveAttribute( - 'data-presentation-scale', - String(scale) - ); - const elapsed = Date.now() - start; + const wallMs = Date.now() - start; const timingPath = info.outputPath(`zoom-${timings.length}-timing.json`); await writeFile( timingPath, - JSON.stringify({ nodes: SIZE, edges: 5000, loadedMs, scale, elapsed }) + JSON.stringify({ nodes: SIZE, edges: 5000, loadedMs, scale, elapsed, wallMs }) ); await info.attach(`zoom-${timings.length}-timing.json`, { path: timingPath, @@ -191,6 +209,7 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom timings.push({ scale, ms: elapsed, + wallMs, paintedCards: geometry.cards.length, labels: geometry.labels.length, }); @@ -202,9 +221,67 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom '2000-items-zoomed-out', 8 ); - timings.push({ scale, ms: elapsed }); + timings.push({ scale, ms: elapsed, wallMs }); } } + const revealId = largeGraph.nodeIds[1011]; + const reveal = page + .locator('.node') + .filter({ + has: page.locator(`.node-edit-icon[data-node-id="${revealId}"]`), + }); + await expect(reveal).toBeHidden(); + const panStart = await page.evaluate(() => + [ + [1200, 300], + [1200, 250], + [1200, 600], + ] + .map(([x, y]) => ({ x, y })) + .find(point => + document + .elementFromPoint(point.x, point.y) + ?.classList.contains('background') + ) + ); + expect(panStart).toBeTruthy(); + await page.mouse.move(panStart!.x, panStart!.y); + await page.mouse.down(); + try { + await page.mouse.move(panStart!.x - 1512, panStart!.y, { steps: 4 }); + await expect(reveal).toBeVisible(); + await expect(reveal).toHaveAttribute('data-detail', 'full'); + const held = await reveal.evaluate(node => { + const card = node.querySelector('.node-bg')!.getBoundingClientRect(); + const title = node + .querySelector('.node-title-group')! + .getBoundingClientRect(); + return { + card: card.toJSON(), + title: title.toJSON(), + fit: + title.left >= card.left && + title.right <= card.right && + title.top >= card.top && + title.bottom <= card.bottom, + }; + }); + expect(held.fit).toBe(true); + const heldPath = info.outputPath('2000-held-pan-reveal.json'); + await writeFile(heldPath, JSON.stringify(held)); + await info.attach('2000-held-pan-reveal.json', { + path: heldPath, + contentType: 'application/json', + }); + const heldImage = info.outputPath('2000-held-pan-reveal.png'); + await page.screenshot({ path: heldImage }); + await info.attach('2000-held-pan-reveal.png', { + path: heldImage, + contentType: 'image/png', + }); + } finally { + await page.mouse.up(); + } await page .getByRole('button', { name: /5000 relationships, .*Browse all relationships/, diff --git a/tests/lib/itemSymbolVisual.ts b/tests/lib/itemSymbolVisual.ts index 1b3e0544..68af734b 100644 --- a/tests/lib/itemSymbolVisual.ts +++ b/tests/lib/itemSymbolVisual.ts @@ -140,7 +140,7 @@ export async function readItemSymbols(page: Page) { for (const title of card.titles) { if (!contains(card.box, title.box)) violations.push(`title-clipped:${card.id}`); - if (title.font < 11.9) violations.push(`title-small:${card.id}`); + if (title.font < 13.9) violations.push(`title-small:${card.id}`); } for (const [index, part] of card.parts.entries()) { if (!contains(card.box, part.box)) @@ -157,7 +157,7 @@ export async function readItemSymbols(page: Page) { violations.push(`label-clipped:${label.id}`); if (!contains(viewport, label.box)) violations.push(`label-offscreen:${label.id}`); - if (label.font < 11.9 || label.opacity < 0.99) + if (label.font < 13.9 || label.opacity < 0.99) violations.push(`label-unreadable:${label.id}`); if ( [ diff --git a/tests/lib/schematicOverview.ts b/tests/lib/schematicOverview.ts index 7ef236d1..62b53cf9 100644 --- a/tests/lib/schematicOverview.ts +++ b/tests/lib/schematicOverview.ts @@ -39,6 +39,11 @@ export async function inspectSchematicOverview( width: bounds.width, height: bounds.height, visible: visible(element), + titleSize: element.hasAttribute('data-lod-group') + ? parseFloat( + getComputedStyle(element.querySelector('span')!).fontSize + ) + : null, }; }; const cards = Array.from(host.querySelectorAll('[data-lod-group]')).map( @@ -99,7 +104,9 @@ export async function inspectSchematicOverview( }, }; }); - await expect.poll(async () => (await read()).cards.length).toBe(expectedCards); + await expect + .poll(async () => (await read()).cards.length) + .toBe(expectedCards); await expect .poll(async () => (await read()).routes.length) .toBeGreaterThan(0); @@ -111,6 +118,7 @@ export async function inspectSchematicOverview( 'Schematic card must have visible width' ).toBeGreaterThan(0); expect(card.height).toBeGreaterThan(0); + expect(card.titleSize).toBeGreaterThanOrEqual(18); expect(card.x).toBeGreaterThanOrEqual(geometry.bounds.left); expect(card.x + card.width).toBeLessThanOrEqual(geometry.bounds.right); expect(card.y).toBeGreaterThanOrEqual(geometry.bounds.top); From 5b16ce93020068ae251b1dc856cde5f6fc0537b0 Mon Sep 17 00:00:00 2001 From: Matthew Valancy <matthew.valancy@gmail.com> Date: Wed, 28 Oct 2026 08:26:54 -0700 Subject: [PATCH 11/19] Place labels in visible route spans and verify painted zoom readiness --- ...readable-symbols-need-reserved-geometry.md | 6 +- .../components/SemanticGraphOverview.test.tsx | 21 +++ .../src/components/SemanticGraphOverview.tsx | 55 +++---- .../lib/__tests__/edgeLabelPlacement.test.ts | 19 +++ packages/web/src/lib/edgeRouteGeometry.ts | 41 +++++- tests/e2e/graph/item-symbols.spec.ts | 38 +++++ tests/e2e/graph/large-graph-symbols.spec.ts | 59 +++----- tests/lib/graphZoomReady.ts | 137 ++++++++++++++++++ 8 files changed, 304 insertions(+), 72 deletions(-) create mode 100644 tests/lib/graphZoomReady.ts diff --git a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md index 4192e728..0f7ac1a2 100644 --- a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md +++ b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md @@ -34,8 +34,12 @@ Independent review and executed scale evidence, 2026-10-28: The owner's follow-up rejects tiny type and plain boxes as the visual direction. The revised contract raises full item/overview titles to18px, compact title and edge-label floors to14px, and graph controls to readable14–16px type. Overview cards reserve240x96px; controls reserve their actual two-line height. Subtle layered fills and a restrained teal heading accent distinguish groups. CSS letter spacing on item type text is neutralized so canvas measurement matches displayed header width. -The6d81752 CI40709921026 large case still failed first zoom at6807ms under the original runner-wall-clock measurement. Its trace shows the timed region included Playwright selector/attribute calls with substantial snapshot/protocol costs (`call867`180338–180836ms, `call869`181294–185485ms, then separate state assertions). The corrected test records both wall time and an in-browser request-to-two-settled-animation-frame duration, with the same5000ms browser budget. This changes the measured boundary and must not be represented as the old wall-time check passing or as a proven performance gain. Both prior failures remain evidence. +The6d81752 CI40709921026 large case still failed first zoom at6807ms under the original runner-wall-clock measurement. Its trace shows the timed region included Playwright selector/attribute calls with substantial durations that mix application waiting and protocol/snapshot work; the trace does not isolate those costs (`call867`180338–180836ms, `call869`181294–185485ms, then separate state assertions). The corrected test records both wall time and an in-browser request-to-two-settled-animation-frame duration, with the same5000ms browser budget. This changes the measured boundary and must not be represented as the old wall-time check passing or as a proven performance gain. Both prior failures remain evidence. Independent review5684764521 also reproduced stale compact cards exposed during a held pan. Visibility reconciliation now applies current item geometry before exposing each newly visible card, bounded to changed nodes; the large gate includes a held-pan/reveal assertion. Browse focus restoration now preserves an unrelated control focused before the scheduled return.54 relevant geometry/hierarchy/relationship unit cases passed after the typography/layout change; strict final browser and CI acceptance remain required. Revised18px/14px typography passed both focused headed desktop/phone cases in23.5s, one worker and zero retries, with strict cleanup and the same deliberate clipping/overlap controls. Scoped web TypeScript passed. This does not replace the pending final large-graph, hierarchy, phone and workflow CI artifacts. + +Independent review5685069307 found the in-browser timer could finish while the parent CSS still hid item layers: child `data-active=false` precedes the passive parent update. The timer now requires the parent gate, actually visible bounded cards and readable titles/labels (or visible overview summaries) for two frames. A deliberate1200ms visibility mask must be included in the measured interval. Hidden overview layout/routing is skipped; projection/accounting remains available and a focused test requires fresh summaries after a hidden live update and return. These changes require subsequent executed validation; neither state attributes nor revised timing alone prove scale acceptance. + +Exact5d CI40713075178 passed the small item consumer/symbol cases but failed the2000-item case on zero visible labels. Its retained timing was3709ms browser/5011ms wall and its overview accounting/clutter passed; this is not full scale acceptance, and the timer at that revision had the review gap above. The route search sampled whole-segment midpoint/end positions, missing the visible middle slice of a long connection. Label candidate ranges now intersect the viewport reduced by the full rotated label footprint, then use the same bounded samples/collision checks; a10000-unit route regression covers this case.24 scoped route/overview cases and web TypeScript passed. Both updated headed desktop/phone cases passed in23.3s, including the deliberate1200ms visibility delay, after correcting the shared timing helper to allow small graphs without an overview layer. The initial negative-control run failed on that missing optional layer; its trace is retained. All local probes still reuse owned dependencies. diff --git a/packages/web/src/components/SemanticGraphOverview.test.tsx b/packages/web/src/components/SemanticGraphOverview.test.tsx index ab0cf4dd..e19d6a27 100644 --- a/packages/web/src/components/SemanticGraphOverview.test.tsx +++ b/packages/web/src/components/SemanticGraphOverview.test.tsx @@ -8,6 +8,7 @@ import { } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { SemanticGraphOverview } from './SemanticGraphOverview'; +import * as routing from '../lib/schematicRouting'; import type { SemanticItem } from '../lib/semanticLOD'; const items: SemanticItem[] = Array.from({ length: 4 }, (_, index) => ({ @@ -130,3 +131,23 @@ it('restores readable Auto summaries when returning within the overview visibili fireEvent.click(screen.getByRole('button', { name: 'Back to overview' })); expect(ids()).toEqual(before); }); + +it('does no hidden overview routing and resumes the current summaries on return', () => { + const route = vi.spyOn(routing, 'routeSchematicConnections'); + render(<Fixture />); + expect(route).toHaveBeenCalled(); + act(() => changeScale(1.2)); + expect(summaries()).toHaveLength(0); + route.mockClear(); + act(() => replaceItems(items.slice(0, 2))); + act(() => changeScale(1.8)); + expect(route).not.toHaveBeenCalled(); + expect( + screen.getByRole('button', { name: '2 groups · 2 items' }) + ).toBeVisible(); + act(() => changeScale(0.2)); + expect(route).toHaveBeenCalled(); + expect(summaries()).toHaveLength(1); + expect(summaries()[0]).toHaveAccessibleName(/System, 2 items/); + route.mockRestore(); +}); diff --git a/packages/web/src/components/SemanticGraphOverview.tsx b/packages/web/src/components/SemanticGraphOverview.tsx index 0ac298a6..80216a6f 100644 --- a/packages/web/src/components/SemanticGraphOverview.tsx +++ b/packages/web/src/components/SemanticGraphOverview.tsx @@ -203,6 +203,7 @@ export function SemanticGraphOverview({ } }, [groupId, group]); const layout = useMemo(() => { + if (!active) return { placed: [], hidden: [] }; if (traceProjection) { return placeOverviewLabels( traceProjection.groups.map((entry, index) => ({ @@ -272,29 +273,33 @@ export function SemanticGraphOverview({ const groupsById = new Map(projection.groups.map(entry => [entry.id, entry])); const wiring = useMemo( () => - routeSchematicConnections( - layout.placed, - (traceProjection?.bundles ?? projection.bundles).map(bundle => ({ - id: bundle.id, - source: bundle.source, - target: bundle.target, - type: bundle.type, - edgeIds: bundle.edges.map(edge => edge.id), - })), - size.width, - size.height, - obstacles.map((rect, index) => ({ - id: `control-${index}`, - x: rect.left, - y: rect.top, - width: rect.right - rect.left, - height: rect.bottom - rect.top, - })), - { - sharedEndpoint: trace?.direction === 'incoming' ? 'target' : 'source', - } - ), + active + ? routeSchematicConnections( + layout.placed, + (traceProjection?.bundles ?? projection.bundles).map(bundle => ({ + id: bundle.id, + source: bundle.source, + target: bundle.target, + type: bundle.type, + edgeIds: bundle.edges.map(edge => edge.id), + })), + size.width, + size.height, + obstacles.map((rect, index) => ({ + id: `control-${index}`, + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + })), + { + sharedEndpoint: + trace?.direction === 'incoming' ? 'target' : 'source', + } + ) + : null, [ + active, layout, projection, traceProjection, @@ -304,7 +309,7 @@ export function SemanticGraphOverview({ obstacleKey, ] ); - const drawnConnections = wiring.routes.reduce( + const drawnConnections = (wiring?.routes ?? []).reduce( (count, bus) => count + bus.edgeIds.length, 0 ); @@ -498,7 +503,7 @@ export function SemanticGraphOverview({ <path d="M 0 0 L 10 5 L 0 10 z" fill="#94a3b8" /> </marker> </defs> - {wiring.routes.map(bus => ( + {wiring?.routes.map(bus => ( <path key={bus.id} data-lod-bundle={bus.connections @@ -524,7 +529,7 @@ export function SemanticGraphOverview({ <title>{`${groupsById.get(bus.source)!.label} → ${groupsById.get(bus.target)!.label}: ${bus.edgeIds.length} relationships · ${bus.connections.map(connection => `${connection.edgeIds.length} ${connection.type}`).join(', ')}`} ))} - {wiring.junctions.map(point => ( + {wiring?.junctions.map(point => ( expect([...result.placements.keys()]).toEqual(['visible']); expect(result.attempts).toBe(1); }); + +it('places labels along the visible slice of a long route without changing its identity', () => { + const long = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 10000, y: 0 }, + ])!; + const result = placeItemLabels([{ ...candidate('long'), route: long }], { + viewport: { x: 9000, y: 0, width: 800, height: 400 }, + obstacles: [{ x: 8600, y: 0, width: 300, height: 200 }], + segments: long.segments, + }); + const label = result.placements.get('long'); + expect(label).toBeDefined(); + expect(label!.x).toBeGreaterThan(8800); + expect(label!.x + 52).toBeLessThanOrEqual(9392); + expect(label!.t).toBeCloseTo(label!.x / long.length); + expect(result.attempts).toBe(1); + expect(result.checks).toBeLessThan(ITEM_LABEL_BUDGET.checks); +}); diff --git a/packages/web/src/lib/edgeRouteGeometry.ts b/packages/web/src/lib/edgeRouteGeometry.ts index 55716018..2b0f9107 100644 --- a/packages/web/src/lib/edgeRouteGeometry.ts +++ b/packages/web/src/lib/edgeRouteGeometry.ts @@ -257,11 +257,44 @@ export function edgeRouteLabel( Math.abs(Math.cos(radians)) * dims.height; for (const candidateOffset of options.offsets ?? [offset]) { if (!Number.isFinite(candidateOffset)) continue; + let visibleLow = low, + visibleHigh = high; + if (options.viewport) { + const center = { + x: segment.source.x + Math.sin(radians) * candidateOffset, + y: segment.source.y - Math.cos(radians) * candidateOffset, + }; + for (const [axis, span, footprint] of [ + ['x', 'width', width], + ['y', 'height', height], + ] as const) { + const half = (options.viewport[span] - footprint) / 2 - padding; + const delta = + (segment.target[axis] - segment.source[axis]) / segment.length; + if ( + half < 0 || + (!delta && Math.abs(center[axis] - options.viewport[axis]) > half) + ) { + visibleLow = Infinity; + break; + } + if (!delta) continue; + const a = + segment.start + + (options.viewport[axis] - half - center[axis]) / delta; + const b = + segment.start + + (options.viewport[axis] + half - center[axis]) / delta; + visibleLow = Math.max(visibleLow, Math.min(a, b)); + visibleHigh = Math.min(visibleHigh, Math.max(a, b)); + } + } + if (visibleLow > visibleHigh) continue; for (const distance of new Set([ - clamp(desired, low, high), - (low + high) / 2, - low, - high, + clamp(desired, visibleLow, visibleHigh), + (visibleLow + visibleHigh) / 2, + visibleLow, + visibleHigh, ])) { const local = (distance - segment.start) / segment.length; const x = diff --git a/tests/e2e/graph/item-symbols.spec.ts b/tests/e2e/graph/item-symbols.spec.ts index d8c0e30c..2e2ccb38 100644 --- a/tests/e2e/graph/item-symbols.spec.ts +++ b/tests/e2e/graph/item-symbols.spec.ts @@ -1,5 +1,7 @@ +import { writeFile } from 'node:fs/promises'; import { test as base, expect } from '@playwright/test'; import { login, TEST_USERS } from '../../lib/auth'; +import { measureGraphZoom } from '../../lib/graphZoomReady'; import { gql } from '../../lib/graphRequest'; import { ownedGraphIdsFixture, @@ -191,6 +193,42 @@ for (const width of [1440, 390]) original ); await inspectItemSymbols(page, info, 'restored-after-negatives'); + await page.evaluate(() => { + const zoom = (window as any).miniMapZoom; + (window as any).miniMapZoom = (...args: unknown[]) => { + (window as any).miniMapZoom = zoom; + const mask = document.createElement('style'); + mask.id = 'timing-visibility-negative'; + mask.textContent = + '.nodes-group { visibility: hidden !important; }'; + document.head.append(mask); + (window as any).__visibilityNegativeStarted = performance.now(); + setTimeout(() => mask.remove(), 1200); + zoom(...args); + }; + }); + const visibleMs = await page.evaluate(measureGraphZoom, { + x: -300, + y: target.positionY, + scale: 1.2, + }); + const totalMs = await page.evaluate( + () => performance.now() - (window as any).__visibilityNegativeStarted + ); + expect(totalMs).toBeGreaterThanOrEqual(1200); + expect(visibleMs).toBeGreaterThanOrEqual(1200); + await expect(page.locator('#timing-visibility-negative')).toHaveCount( + 0 + ); + const timingPath = info.outputPath('delayed-visible-content.json'); + await writeFile( + timingPath, + JSON.stringify({ hiddenForMs: 1200, visibleMs, totalMs }) + ); + await info.attach('delayed-visible-content.json', { + path: timingPath, + contentType: 'application/json', + }); } expect(await snapshot()).toEqual(before); expect(writes).toEqual([]); diff --git a/tests/e2e/graph/large-graph-symbols.spec.ts b/tests/e2e/graph/large-graph-symbols.spec.ts index 5030523d..81733f94 100644 --- a/tests/e2e/graph/large-graph-symbols.spec.ts +++ b/tests/e2e/graph/large-graph-symbols.spec.ts @@ -7,6 +7,7 @@ import { type OwnedGraphs, } from '../../lib/ownedGraphIdsFixture'; import type { SeededGraph } from '../../lib/seedGraph'; +import { measureGraphZoom } from '../../lib/graphZoomReady'; import { inspectItemSymbols } from '../../lib/itemSymbolVisual'; import { inspectSchematicOverview } from '../../lib/schematicOverview'; @@ -133,47 +134,23 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom }[] = []; for (const scale of [1.2, 0.1, 1.2]) { const start = Date.now(); - const elapsed = await page.evaluate( - async ({ x, y, scale }) => { - const start = performance.now(); - const host = document.querySelector('.graph-container')!; - const svg = host.querySelector(':scope > svg') as SVGSVGElement & { - __zoom: { k: number }; - }; - const group = host.querySelector('.main-graph-group')!; - const overview = host.querySelector('.semantic-overview')!; - (window as any).miniMapZoom(x, y, scale); - return await new Promise((resolve, reject) => { - let settledFrames = 0; - const check = () => { - if (performance.now() - start > 15000) { - reject( - new Error( - 'Zoom did not paint its requested detail within15seconds' - ) - ); - return; - } - if ( - Math.abs(svg.__zoom.k - scale) < 0.00001 && - group.getAttribute('data-presentation-scale') === String(scale) && - overview.getAttribute('data-active') === String(scale < 1) - ) - settledFrames++; - else settledFrames = 0; - if (settledFrames >= 2) resolve(performance.now() - start); - else requestAnimationFrame(check); - }; - requestAnimationFrame(check); - }); - }, - { x: target.positionX, y: target.positionY, scale } - ); + const elapsed = await page.evaluate(measureGraphZoom, { + x: target.positionX, + y: target.positionY, + scale, + }); const wallMs = Date.now() - start; const timingPath = info.outputPath(`zoom-${timings.length}-timing.json`); await writeFile( timingPath, - JSON.stringify({ nodes: SIZE, edges: 5000, loadedMs, scale, elapsed, wallMs }) + JSON.stringify({ + nodes: SIZE, + edges: 5000, + loadedMs, + scale, + elapsed, + wallMs, + }) ); await info.attach(`zoom-${timings.length}-timing.json`, { path: timingPath, @@ -225,11 +202,9 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom } } const revealId = largeGraph.nodeIds[1011]; - const reveal = page - .locator('.node') - .filter({ - has: page.locator(`.node-edit-icon[data-node-id="${revealId}"]`), - }); + const reveal = page.locator('.node').filter({ + has: page.locator(`.node-edit-icon[data-node-id="${revealId}"]`), + }); await expect(reveal).toBeHidden(); const panStart = await page.evaluate(() => [ diff --git a/tests/lib/graphZoomReady.ts b/tests/lib/graphZoomReady.ts new file mode 100644 index 00000000..c34b3eb2 --- /dev/null +++ b/tests/lib/graphZoomReady.ts @@ -0,0 +1,137 @@ +export async function measureGraphZoom({ + x, + y, + scale, +}: { + x: number; + y: number; + scale: number; +}) { + const start = performance.now(); + const host = document.querySelector('.graph-container')!; + const svg = host.querySelector(':scope > svg') as SVGSVGElement & { + __zoom: { k: number }; + }; + const group = host.querySelector('.main-graph-group')!; + const overview = host.querySelector('.semantic-overview')!; + const viewport = host.getBoundingClientRect(); + const contains = (a: DOMRect, b: DOMRect) => + b.width > 0 && + b.height > 0 && + b.left >= a.left - 0.5 && + b.right <= a.right + 0.5 && + b.top >= a.top - 0.5 && + b.bottom <= a.bottom + 0.5; + const visible = (element: Element) => { + for ( + let current: Element | null = element; + current; + current = current.parentElement + ) { + const style = getComputedStyle(current); + if ( + style.display === 'none' || + style.visibility !== 'visible' || + Number(style.opacity) < 0.99 + ) + return false; + } + const box = element.getBoundingClientRect(); + return ( + box.width > 0 && + box.height > 0 && + box.right > viewport.left && + box.left < viewport.right && + box.bottom > viewport.top && + box.top < viewport.bottom + ); + }; + const readable = (element: SVGGraphicsElement) => { + const matrix = element.getScreenCTM(); + return ( + visible(element) && + matrix && + parseFloat(getComputedStyle(element).fontSize) * + Math.hypot(matrix.a, matrix.b) >= + 13.9 + ); + }; + const ready = () => { + if ( + Math.abs(svg.__zoom.k - scale) >= 0.00001 || + group.getAttribute('data-presentation-scale') !== String(scale) || + (overview?.getAttribute('data-active') === 'true') !== scale < 1 || + (host.getAttribute('data-overview') === 'true') !== scale < 1 + ) + return false; + if (scale < 1) { + const summaries = Array.from( + overview.querySelectorAll('[data-lod-group]') + ); + return ( + summaries.length > 0 && + summaries.every( + card => + visible(card) && contains(viewport, card.getBoundingClientRect()) + ) + ); + } + const nodes = Array.from( + host.querySelectorAll('.node') + ).filter(node => node.style.display !== 'none' && visible(node)); + if (!nodes.length || nodes.length >= 100) return false; + const cardsReady = nodes.some(node => { + const box = node.querySelector('.node-bg')!.getBoundingClientRect(); + const titles = Array.from( + node.querySelectorAll('.node-title-text') + ); + return ( + node.getAttribute('data-detail') === 'full' && + contains(viewport, box) && + titles.length > 0 && + titles.every( + title => + readable(title) && contains(box, title.getBoundingClientRect()) + ) + ); + }); + const labels = Array.from( + host.querySelectorAll('.edge-label-group') + ).filter(label => label.style.display !== 'none' && visible(label)); + return ( + cardsReady && + labels.length > 0 && + labels.length <= 32 && + labels.every(label => { + const box = label.querySelector('rect')!.getBoundingClientRect(); + const text = label.querySelector('.edge-label')!; + return ( + contains(viewport, box) && + readable(text) && + contains(box, text.getBoundingClientRect()) + ); + }) + ); + }; + (window as any).miniMapZoom(x, y, scale); + return await new Promise((resolve, reject) => { + let settledFrames = 0; + const check = () => { + if (performance.now() - start > 15000) { + reject( + new Error('Zoom did not paint readable content within 15 seconds') + ); + return; + } + try { + settledFrames = ready() ? settledFrames + 1 : 0; + } catch (error) { + reject(error); + return; + } + if (settledFrames >= 2) resolve(performance.now() - start); + else requestAnimationFrame(check); + }; + requestAnimationFrame(check); + }); +} From 9c564ac30d200f397fe5ae743b698771724f7ab7 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 28 Oct 2026 08:47:10 -0700 Subject: [PATCH 12/19] Keep diagonal labels clear and trace controls readable --- ...readable-symbols-need-reserved-geometry.md | 28 ++++++++ .../SemanticRelationshipBrowser.tsx | 12 ++-- .../src/components/SemanticTraceControls.tsx | 4 +- .../lib/__tests__/edgeRouteGeometry.test.ts | 35 ++++++++++ packages/web/src/lib/edgeRouteGeometry.ts | 31 ++++++++- tests/e2e/graph/item-symbols.spec.ts | 57 +++++++++++++--- tests/e2e/graph/nested-semantic-lod.spec.ts | 12 ++++ tests/lib/itemSymbolVisual.ts | 68 +++++++++++-------- tests/lib/schematicOverview.ts | 37 ++++++++++ 9 files changed, 240 insertions(+), 44 deletions(-) diff --git a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md index 0f7ac1a2..2db8476b 100644 --- a/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md +++ b/.agents/inbox/2026-10-28-readable-symbols-need-reserved-geometry.md @@ -43,3 +43,31 @@ Revised18px/14px typography passed both focused headed desktop/phone cases in23. Independent review5685069307 found the in-browser timer could finish while the parent CSS still hid item layers: child `data-active=false` precedes the passive parent update. The timer now requires the parent gate, actually visible bounded cards and readable titles/labels (or visible overview summaries) for two frames. A deliberate1200ms visibility mask must be included in the measured interval. Hidden overview layout/routing is skipped; projection/accounting remains available and a focused test requires fresh summaries after a hidden live update and return. These changes require subsequent executed validation; neither state attributes nor revised timing alone prove scale acceptance. Exact5d CI40713075178 passed the small item consumer/symbol cases but failed the2000-item case on zero visible labels. Its retained timing was3709ms browser/5011ms wall and its overview accounting/clutter passed; this is not full scale acceptance, and the timer at that revision had the review gap above. The route search sampled whole-segment midpoint/end positions, missing the visible middle slice of a long connection. Label candidate ranges now intersect the viewport reduced by the full rotated label footprint, then use the same bounded samples/collision checks; a10000-unit route regression covers this case.24 scoped route/overview cases and web TypeScript passed. Both updated headed desktop/phone cases passed in23.3s, including the deliberate1200ms visibility delay, after correcting the shared timing helper to allow small graphs without an overview layer. The initial negative-control run failed on that missing optional layer; its trace is retained. All local probes still reuse owned dependencies. + +Independent review5685328335 confirmed the content-aware timing fix, but found +clear45-degree labels rejected by their own wire because collision used the +rotated label's axis-aligned bounds. Wire collision now uses label-local axes; +the visual oracle independently clips segments against actual SVG quadrilaterals. +Four diagonal directions have clear/crossing pure regressions. The new headed +negative also exposed that zero-height horizontal paths were excluded from +painted-wire checks; stroked nonzero-length paths now count. Its first restore +attempt selected a different path after Playwright considered the horizontal +negative hidden; restoration now keeps a stable original path locator. Retain +those failing controls rather than treating them as product passes. + +The headed trace review also found12px controls and overlap with the bottom +Connections control. Trace/browser text is now14px, headings16px, trace height +fits content and its bottom spacing clears the control. Overview checks enforce +the14px floor and trace/control separation, including a deliberate8px negative. +The complete72-item phone hierarchy/trace journey passed in43.8s with one headed +browser, one worker, zero retries and strict cleanup;42 scoped geometry/overview/ +relationship tests and web types passed. Exact5b CI40716064148 failed its actual +2000-item visible-content timeout with zero labels; its artifacts are retained. +The current rotated-label correction still needs fresh scale CI and review. + +The corrected diagonal headed journey passed in13.4s: all four zoom stages have +visible readable labels, the injected horizontal crossing is rejected, restoring +the original path clears it, exact two-record browsing and the1200ms visibility +negative pass, and the API snapshot remains unchanged. The existing horizontal +desktop/phone cases also passed in the preceding35s batch; the batch's diagonal +negative failure is retained separately. diff --git a/packages/web/src/components/SemanticRelationshipBrowser.tsx b/packages/web/src/components/SemanticRelationshipBrowser.tsx index 6a93087d..b28a914d 100644 --- a/packages/web/src/components/SemanticRelationshipBrowser.tsx +++ b/packages/web/src/components/SemanticRelationshipBrowser.tsx @@ -129,7 +129,7 @@ export function SemanticRelationshipBrowser({ > {item.title} -

{contextFor(id)}

+

{contextFor(id)}

@@ -145,10 +145,10 @@ export function SemanticRelationshipBrowser({ ) : ( <> {id} -

Endpoint not loaded

+

Endpoint not loaded

)} - {item &&

{id}

} + {item &&

{id}

}
); }; @@ -187,7 +187,7 @@ export function SemanticRelationshipBrowser({ This group is no longer present in the loaded graph.

)} -

+

Source → target follows the stored relationship direction.

{scope.kind === 'node' && ( @@ -281,7 +281,7 @@ export function SemanticRelationshipBrowser({ data-lod-edge-id={edge.id} className="rounded border border-gray-700 bg-gray-900 p-3" > -

{edge.id}

+

{edge.id}

{onEdit && ( @@ -498,7 +498,7 @@ export function CreateWorkItemModal({ isOpen, onClose, parentWorkItemId, positio
{formData.status === option.value && ( -
+
✓
)} @@ -663,7 +663,7 @@ export function CreateWorkItemModal({ isOpen, onClose, parentWorkItemId, positio 'accent-gray-500' }`} /> -
= 0.8 ? 'text-red-500' : formData.priority >= 0.6 ? 'text-orange-500' : formData.priority >= 0.4 ? 'text-yellow-500' : diff --git a/packages/web/src/components/GraphSelector.tsx b/packages/web/src/components/GraphSelector.tsx index 311cdd82..ae673b9c 100644 --- a/packages/web/src/components/GraphSelector.tsx +++ b/packages/web/src/components/GraphSelector.tsx @@ -466,7 +466,7 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI
{child.name}
-
{child.nodeCount || 0} nodes • {child.edgeCount || 0} edges
+
{child.nodeCount || 0} nodes • {child.edgeCount || 0} edges
))} @@ -516,7 +516,7 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI {/* Tooltip Portal */} {hoveredTooltip && createPortal(
(d.strength || 0.8) * 3); // Reset to normal thickness + .attr('stroke-width', ITEM_WIRE_WIDTH); // Reset to normal thickness // Apply type-specific glow to active node if nodeMenu is visible if (nodeMenu.visible && nodeMenu.node) { @@ -2075,6 +2075,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .enter() .append('path') .attr('fill', 'none') + .attr('vector-effect', 'non-scaling-stroke') + .attr('stroke-linecap', 'round') + .attr('stroke-linejoin', 'round') .attr('data-edge-id', (d: WorkItemEdge) => d.id) .attr('class', (d: WorkItemEdge) => { let classes = 'edge'; @@ -2104,14 +2107,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (editingEdge && editingEdge.edge && editingEdge.edge.id === d.id) { return 10; // Very thick to make it obvious } - return (d.strength || 0.8) * 3; + return ITEM_WIRE_WIDTH; }) .attr('stroke-opacity', (d: WorkItemEdge) => { // Full opacity for active dialog if (editingEdge && editingEdge.edge && editingEdge.edge.id === d.id) { return 1; } - return 0.7; + return 0.9; }) .style('filter', (d: WorkItemEdge) => { // Apply pulsing glow to edges with active dialogs @@ -2145,8 +2148,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const config = getRelationshipConfig(d.type as RelationshipType); d3.select(this) .style('stroke', config.hexColor) - .style('stroke-width', (d.strength || 0.8) * 3) - .style('stroke-opacity', '0.7') + .style('stroke-width', ITEM_WIRE_WIDTH) + .style('stroke-opacity', '0.9') .style('filter', null); }); @@ -2158,6 +2161,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .append('path') .attr('class', 'edge-clickable') .attr('fill', 'none') + .attr('vector-effect', 'non-scaling-stroke') + .attr('stroke-linecap', 'round') + .attr('stroke-linejoin', 'round') .attr('data-edge-id', (d: WorkItemEdge) => d.id) .attr('stroke', 'transparent') .attr('stroke-width', 12) @@ -2168,15 +2174,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Highlight the corresponding visible edge linkElements .filter((edge: WorkItemEdge) => edge.id === d.id) - .attr('stroke-width', (d: WorkItemEdge) => ((d.strength || 0.8) * 3) + 2) + .attr('stroke-width', ITEM_WIRE_WIDTH + 2) .attr('stroke-opacity', 1); }) .on('mouseout', function(event: MouseEvent, d: WorkItemEdge) { // Reset the corresponding visible edge linkElements .filter((edge: WorkItemEdge) => edge.id === d.id) - .attr('stroke-width', (d: WorkItemEdge) => (d.strength || 0.8) * 3) - .attr('stroke-opacity', 0.7); + .attr('stroke-width', ITEM_WIRE_WIDTH) + .attr('stroke-opacity', 0.9); }); // Create or get defs element for filters @@ -2476,7 +2482,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } // Thicker border for selected node if (selectedNode && selectedNode.id === d.id) { - return 3; + return ITEM_WIRE_WIDTH; } if (d.subgraphId) { return 2.5; // Sheet symbol — emphasize it's a container @@ -2898,7 +2904,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('x', 0) .attr('y', (d: WorkItem) => getNodeDimensions(d).height / 2 - 10) .attr('text-anchor', 'middle') - .style('font-size', '9px') + .style('font-size', '14px') .style('font-weight', '600') .style('fill', '#a5b4fc') .style('pointer-events', 'none') @@ -2922,7 +2928,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.CLOSE ? 1 : 0) .style('font-size', '14px') .style('font-family', GRAPH_FONT_FAMILY) - .style('font-weight', '400') + .style('font-weight', '500') .style('fill', (d: WorkItem) => { const isCompleted = d.status === 'COMPLETED' || d.status === 'Completed' || d.status === 'Done' || d.status === 'DONE'; return isCompleted ? '#6b7280' : '#d1d5db'; @@ -3791,7 +3797,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (editingEdge && editingEdge.edge && editingEdge.edge.id === d.id) { return 10; // Keep thick for active dialog } - return scale >= LOD_THRESHOLDS.FAR ? 1 : Math.max(0.3, scale * 0.8); + return ITEM_WIRE_WIDTH; }); }); diff --git a/packages/web/src/components/MobileBottomNav.tsx b/packages/web/src/components/MobileBottomNav.tsx index 95e769e8..6bde84a6 100644 --- a/packages/web/src/components/MobileBottomNav.tsx +++ b/packages/web/src/components/MobileBottomNav.tsx @@ -77,18 +77,18 @@ export function MobileBottomNav() { > @@ -155,7 +155,7 @@ export function MobileBottomNav() {
{currentUser?.name || currentUser?.username || 'Account'}
- {currentUser?.role &&
{currentUser.role}
} + {currentUser?.role &&
{currentUser.role}
}
) : ( @@ -1318,7 +1318,7 @@ export function WorkItemDetailsModal({ Work Item ID - {currentNode.id} + {currentNode.id} diff --git a/packages/web/src/components/WorkItemTypeSelector.tsx b/packages/web/src/components/WorkItemTypeSelector.tsx index 2dce79e1..70154152 100644 --- a/packages/web/src/components/WorkItemTypeSelector.tsx +++ b/packages/web/src/components/WorkItemTypeSelector.tsx @@ -79,7 +79,7 @@ export function WorkItemTypeSelector({
{selectedNodeType.label} -
{selectedNodeType.description}
+
{selectedNodeType.description}
) : ( @@ -114,7 +114,7 @@ export function WorkItemTypeSelector({ ? 'text-emerald-400' : 'text-gray-100' }`}>{nodeType.label} -
{selectedType === nodeType.value && ( -
+
✓
)} diff --git a/packages/web/src/components/WorkspaceViewTabs.tsx b/packages/web/src/components/WorkspaceViewTabs.tsx new file mode 100644 index 00000000..c5d8dbf7 --- /dev/null +++ b/packages/web/src/components/WorkspaceViewTabs.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { + Activity, + CalendarDays, + Columns, + CreditCard, + GanttChartSquare, + LayoutDashboard, + Network, + Table, +} from 'lucide-react'; +import type { ViewMode } from '../contexts/ViewModeContext'; + +const views = [ + { id: 'graph', name: 'Graph', icon: Network }, + { id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard }, + { id: 'table', name: 'Table', icon: Table }, + { id: 'cards', name: 'Card', icon: CreditCard }, + { id: 'kanban', name: 'Kanban', icon: Columns }, + { id: 'gantt', name: 'Gantt', icon: GanttChartSquare }, + { id: 'calendar', name: 'Calendar', icon: CalendarDays }, + { id: 'activity', name: 'Activity', icon: Activity }, +] as const; + +export function WorkspaceViewTabs({ + value, + onChange, +}: { + value: ViewMode; + onChange: (value: ViewMode) => void; +}) { + const [tooltip, setTooltip] = useState(null); + return ( +
+ {views.map(({ id, name, icon: Icon }) => ( + + ))} +
+ ); +} diff --git a/packages/web/src/index.css b/packages/web/src/index.css index afe12a75..f0db4734 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -109,6 +109,7 @@ viewport, including when a long sign-in page has already scrolled. */ body { @apply bg-gray-900 text-gray-100; + font-weight: 500; -webkit-backface-visibility: hidden; backface-visibility: hidden; } @@ -801,7 +802,8 @@ html[data-theme="contrast"] .node-selected { .workspace-surface, .signin-surface { background-color: var(--gd-canvas); color: var(--gd-ink); - font-family: 'Segoe UI', 'Helvetica Neue', sans-serif; + font-family: system-ui, sans-serif; + font-weight: 500; -webkit-font-smoothing: antialiased; } .signin-surface::before { @@ -818,18 +820,29 @@ html[data-theme="contrast"] .node-selected { .signin-surface button[class*="bg-gradient"] { background-image: none; background-color: var(--gd-action); box-shadow: none; } .signin-surface button:hover { transform: none; } .workspace-toolbar { background: var(--gd-chrome); border-color: var(--gd-line); } -.workspace-view-switcher { display: flex; align-items: center; gap: 2px; } +.workspace-view-switcher { + display: flex; align-items: center; gap: 6px; padding: 5px; + border: 1px solid var(--gd-line); border-radius: 16px; background: #11182780; +} .workspace-view-tab { - display: flex; align-items: center; justify-content: center; gap: 7px; - min-height: 44px; padding: 10px 9px; white-space: nowrap; - border-bottom: 2px solid transparent; color: var(--gd-muted); - transition: color 120ms, border-color 120ms; -} -.workspace-view-tab > svg { display: none; } -.workspace-view-tab > div { font-size: 12px; font-weight: 500; } -.workspace-view-tab:hover { color: var(--gd-ink); border-color: var(--gd-line); } -.workspace-view-tab[aria-pressed="true"] { color: var(--gd-accent); border-color: var(--gd-accent); } -@media (min-width: 1600px) { .workspace-view-tab > svg { display: block; width: 17px; height: 17px; } } + position: relative; display: flex; align-items: center; justify-content: center; + width: 44px; height: 44px; flex-shrink: 0; border-radius: 11px; + color: #bdc9d9; border: 1px solid transparent; + transition: color 120ms, background-color 120ms, border-color 120ms; +} +.workspace-view-tab > svg { display: block; width: 24px; height: 24px; } +.workspace-view-tab:hover { color: #fff; background: #334155; } +.workspace-view-tab[aria-pressed="true"] { + color: #b3ffe8; background: #25473f; border-color: #5eead4; + box-shadow: inset 0 -3px #5eead4; +} +.workspace-view-tooltip { + position: absolute; top: calc(100% + 10px); left: 50%; transform: translateX(-50%); + padding: 7px 12px; border: 1px solid #64748b; border-radius: 8px; + background: #0f172a; color: #f1f5f9; font-size: 14px; font-weight: 600; + line-height: 20px; white-space: nowrap; pointer-events: none; z-index: 100; + box-shadow: 0 6px 20px #0005; +} .workspace-create { background: var(--gd-action); color: #f5f4ee; border: 1px solid #a68cff; } .workspace-create:hover { background: #7e63f0; } .project-switcher { background: var(--gd-surface); border-color: var(--gd-line); box-shadow: 0 1px 2px #0002; } @@ -840,8 +853,8 @@ html:not([data-theme="contrast"]) .workspace-surface .graph-container .node-bg { .workspace-surface .graph-container .node-title-bar { fill-opacity: .55; stroke-opacity: .65; } .workspace-surface .graph-container .node-type-text { letter-spacing: .06em; } html:not([data-theme="contrast"]) .workspace-surface .graph-container .node-title-text { fill: var(--gd-ink); } -.workspace-surface .graph-container .edge { stroke-opacity: .55; } -.workspace-surface .graph-container .edge-label-bg { stroke-opacity: .55; } +.workspace-surface .graph-container .edge { stroke-opacity: .9; } +.workspace-surface .graph-container .edge-label-bg { stroke-opacity: .9; } .workspace-surface button:focus-visible, .signin-surface button:focus-visible { outline: 2px solid var(--gd-accent); outline-offset: 3px; } @@ -850,7 +863,6 @@ html:not([data-theme="contrast"]) .workspace-surface .graph-container .node-titl .signin-headline-accent { color: #f6a280; } .work-illustration { display: none; width: 100%; max-width: 420px; margin-top: 28px; } @media (min-width: 1024px) and (min-height: 700px) { .work-illustration { display: block; } } -.workspace-view-tab[aria-pressed="true"] { background: #302951; border-radius: 6px 6px 0 0; } .signin-card { border-top: 3px solid #9678ff; } .workspace-surface nav a[aria-current="page"] { background: #342e59; color: #e0d4ff; box-shadow: inset 3px 0 #a186ff; } diff --git a/packages/web/src/lib/graphSymbolLayout.ts b/packages/web/src/lib/graphSymbolLayout.ts index d4a743eb..4d692345 100644 --- a/packages/web/src/lib/graphSymbolLayout.ts +++ b/packages/web/src/lib/graphSymbolLayout.ts @@ -3,6 +3,7 @@ export const ITEM_TITLE_SIZE = 18; export const ITEM_TITLE_LINE = 24; export const ITEM_HEADER_HEIGHT = 34; export const EDGE_LABEL_SIZE = 14; +export const ITEM_WIRE_WIDTH = 3; export const EDGE_LABEL_HEIGHT = 30; export type TextMeasure = ( diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index f94eef4a..bbc0d337 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -1,10 +1,11 @@ import {COMPACT_WORKSPACE_QUERY} from '../hooks/useCompactWorkspace'; import { offlineEnabled } from '../lib/offline/engine'; import { skipBackgroundPoll, useHostedUsage } from '../lib/hostedUsage'; +import { WorkspaceViewTabs } from '../components/WorkspaceViewTabs'; import { AgentConnection } from '../components/AgentConnection'; import type { WorkItem } from '../types/graph'; import { useState, useEffect, useCallback } from 'react'; -import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Maximize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight, Lock, Unlock } from 'lucide-react'; +import { Plus, Share2, Users, Network, Database, AlertTriangle, Map, X, Minimize2, Maximize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight, Lock, Unlock } from 'lucide-react'; import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; import { SafeGraphVisualization } from '../components/SafeGraphVisualization'; @@ -169,97 +170,8 @@ export function Workspace() { {/* Center Section: View Mode Buttons — desktop/tablet only. Phones use the bottom tab bar (List / Graph / More) instead of this strip. */} -
-
- - -