From eee4063c43e619a33c48fd0408bb9dc2dd45cc92 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 23:02:18 -0700 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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(
=