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..24de23b6 --- /dev/null +++ b/.agents/inbox/2026-10-28-hidden-labels-need-valid-measurement.md @@ -0,0 +1,87 @@ +# 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. + +Independent review of `9931b3c` required stronger browser oracles before +acceptance: pinned physics can be at rest while the450ms Fit transition is +still moving the camera, and paint checks must inspect ancestors and actual +stroke/fill. The correction waits for both `atRest` and `cameraReady`, rejects +hidden ancestors/absent paint, and deliberately hides wire and arrow groups or +removes a wire stroke to prove rejection. It also compares route endpoints to +the original API-directed cards and checks the editor's original direction and +selected type. Relationship type buttons expose their selected state through +`aria-pressed`. Local discovery still finds2 cases and scoped web types pass; +actual browser acceptance is pending. These were source-review findings, +not locally executed browser negatives. + +On2026-10-28, exact source `93fe6d2` CI40646515194 failed the hidden-size case: +expected at least104, received94.90625. The other item-wire case passed, +including paint negatives and actual dragging/editing. Chromium had returned +valid measured text dimensions smaller than the conservative estimate; the +assertion incorrectly assumed hidden SVG always measures zero. This is a test +precondition defect, not evidence that valid94.90625-unit geometry clips text. +The amended case deliberately returns an empty box only for hidden edge text, +counts that branch and retains the full-size/background/visibility assertions. + +The owner then requested a visible headed browser. A single installed Chrome, +one worker, zero retries, on isolated preview28917/API28918 and the previously +owned Neo4j28687 exercised the regression. With only the positive-measurement +production guard removed from the disposable preview, the actual case failed +with width36 versus104, after confirming an empty measurement. Restoring the +unchanged production guard made both item-wire cases pass in21.9s. Traces, +screenshots and logs are retained under `item-hidden-negative` and +`item-wires-headed-positive` in the20261027 evidence directory. This reused a +matching dependency installation for scoped preview evidence; it is not a +clean-install/full-tree acceptance run. The source PR remains subject to fresh +CI and independent review. The owner's further feedback about clipping, +overlap, tiny text and wasteful cards requires broader visual acceptance; +passing these two interaction cases does not resolve that feedback. 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..79e829a6 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,8 @@ | 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 | | 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/.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 9b140821..f46bf9bb 100644 --- a/docs/architecture/schematic-connections.md +++ b/docs/architecture/schematic-connections.md @@ -115,3 +115,71 @@ 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 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. + +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. + +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. + +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. + +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. + +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 3b92e8c2..725f4f53 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 { 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'; @@ -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 + 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, @@ -2168,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'); @@ -2176,7 +2189,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,36 +2271,15 @@ 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(); - - // 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 @@ -3356,10 +3350,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,22 +3380,11 @@ 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') - .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. @@ -3410,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') @@ -3430,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) { @@ -3462,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) => { @@ -3491,18 +3479,22 @@ 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; if (textElement) { const bbox = textElement.getBBox(); + if (!(bbox.width > 0 && bbox.height > 0)) return; const iconWidth = 14; const spacing = 8; const paddingLeft = 6; 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 +3516,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('dominant-baseline', 'middle'); } }); + updateEdgePositions(true); }, 150); // Node click handling with edge creation @@ -3819,174 +3812,101 @@ 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; - } - } - d._ep = ep; + 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); + 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 +3961,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 +3993,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/packages/web/src/components/RelationshipEditorWindow.tsx b/packages/web/src/components/RelationshipEditorWindow.tsx index 5121836d..17b68d7c 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(
=