Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .agents/inbox/2026-10-29-hidden-items-must-not-delay-overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Hidden item rendering must not delay the visible overview

## Evidence — 2026-10-29 UTC

Actual post-merge `dev` revision `17acc25dc98978c64f87a5b0d6fb36b1833cdfa7`
failed the unchanged five-second browser zoom guard in
[CI run 40812241527](https://github.com/GraphDone/GraphDone-Core/actions/runs/40812241527).
The [retained smoke artifact](https://github.com/GraphDone/GraphDone-Core/actions/runs/40812241527/artifacts/12870958655)
contains `test-artifacts/ci-large-symbols/report.json` and its `large-symbols`
assets: `9.json` (detail zoom), `12.json` (overview zoom), `15.zip` (trace).
Recover with `gh run download 40812241527 --repo GraphDone/GraphDone-Core --name smoke-results-17acc25dc98978c64f87a5b0d6fb36b1833cdfa7`.

The real 2,000-item / 5,000-edge case loaded in 27,088 ms. Its detail zoom took
3,745.5 ms and overview zoom took 5,119.1 ms against `< 5,000 ms`. The case failed
at 125,186 ms, below its 240-second total timeout. The new bus-inspection journey
had completed in 45,516 ms. Only four of thirteen strict reports ran; the later
zoom, final snapshot, no-write and error guards were not reached. Comprehensive
Tests run 40812241568 passed, which does not replace this failed browser guard.

## Cause and working hypothesis

`InteractiveGraphVisualization` clears item culling below scale 0.5, then the
zoom-end callback rebuilds item titles/metadata and forces original-edge routing
and label work. Its source groups are CSS-hidden while semantic overview owns
the display. That control flow is verified; the exact share of the 5,119 ms
attributable to hidden work is not yet measured. Previous pre-merge success is
not evidence that the actual integration passed.

## Corrective rule

Let the visible presentation own expensive rendering. Skip hidden item graphics
and coalesce terminal camera work, while preserving source simulation, canonical
selection, layout and camera ownership. Refresh actual cards and relationships
when returning to Items, including manual Items selection with no camera event.
Keep the five-second zoom and 45-second load guards and retain the original
failure. Validate correctness with small serial controls locally; measure the
real 2k journey and all final guards on hosted exact-revision CI.

## Limits and tracking

This is issue [#235](https://github.com/GraphDone/GraphDone-Core/issues/235), linked
to #231/#193. It does not claim to fix placement/routing spacing (#220/#233/#234),
change source layout ownership, or establish a timing improvement before new
measurements. Initial failed-run fingerprint:
`34c6cda2542943a3e65e610b3c92dd9f94e0f837e7f418f42f3dcb8a617337b5`.

## Correction and local controls — 2026-10-29 UTC

`useItemPresentation` coalesces terminal zoom work into one animation frame after
React commits the active presentation. Overview activation/deactivation is
reported in layout effects. Hidden source culling, item opacity and original-edge
routing/label work are skipped. Source simulation and camera ownership are
unchanged; the geometry scale still follows the terminal camera. Returning to
Items clears the item presentation cache and refreshes current cards, opacity,
culling and routes before paint, including manual Items without a camera event.
The scheduler cancels detached work and checks the current simulation.

Local commands, run serially with Node 22 and one Vitest worker:

- `npm run test --workspace=@graphdone/web -- --maxWorkers=1 src/hooks/useItemPresentation.test.tsx src/components/SemanticGraphOverview.test.tsx src/components/OverviewBusLayer.test.tsx src/lib/overviewBusHit.test.ts`: **16 passed / 4.86 s**.
- `npm run typecheck --workspace=@graphdone/web`: passed. The first local check
caught a D3 selection union on chained `attr`; individual attribute writes
corrected it before publication.
- Scoped ESLint on both presentation components and the new hook/test: **0 errors**,
302 existing-style warnings in the large graph component.
- Playwright discovery finds the existing three 301-item desktop/phone and 2k
cases. No local browser or large fixture was launched.

Two retained local negative controls establish the regressions: replacing the
hidden branch with unconditional item rendering fails two tests (hidden work
appears); suppressing the Items-return refresh also fails two tests (the displayed
card remains at scale 1.2/revision 0 instead of scale 0.2/revision 1). Corrected
source passes both. The hook test includes the actual `SemanticGraphOverview`
mode selector and automatic exit, and asserts current SVG text/path plus visible
host ownership. This small control is not a full-application timing measurement.

Existing browser journeys now capture actual item-presentation pass/scale
attributes, require zero item passes during the 2k overview zoom, and check real
cards and paths after manual Items selection. These are additional assertions,
not substituted readiness or timing gates. Hosted exact-source acceptance and
independent review remain required before claiming the performance issue solved.

## Review correction: route before restoring arrow paint

Author self-audit and the independent reviewer identified a return-order problem
in the initial implementation (`968e956`). Arrows created during hidden overview
start at opacity zero. Applying opacity before publishing routes filters those
arrows out; newly represented priority edges have the same problem. A small
D3/JSDOM probe extracted the actual `updateZoomPresentation` callback and used
actual `edgeLOD` functions: old order retained opacity **0**, while route-first
produced opacity **1** at scale 1.2. This is a demonstrated rendering defect, not
a timing inference. The return now establishes routes before restoring opacity.
The existing desktop/phone journey additionally changes priority while hidden,
returns through the actual Items selector, and records/asserts current arrow
transforms and full opacity. Its JSON and screenshots are retained. Fresh final
source CI is required; the earlier `968e956` run is not final acceptance.
1 change: 1 addition & 0 deletions .agents/issues/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

| Symptom | Canonical lesson and regression |
| --- | --- |
| Overview zoom exceeds five seconds while original item groups are hidden | [Hidden items must not delay overview](../inbox/2026-10-29-hidden-items-must-not-delay-overview.md); issue #235, retained actual-dev 2k failure |
| Overview wires cannot be hovered, do not light endpoints, or lose focus when their route disappears | [Interaction geometry and original-edge inspection](../inbox/2026-10-28-overview-wires-need-interaction-geometry.md); `OverviewBusLayer`, semantic LOD and 2k bus inspection journeys |
| Your agent mixes setup, existing access and notes; narrow permission text clips, or unavailable access looks empty | [Separate task regions and honest availability](../inbox/2026-10-28-agent-setup-needs-separate-task-regions.md); `agent-connection` browser sequence and `AgentConnection.test.tsx` |
| Inspector code remains11–13px, nested previews have microscopic labels, long titles hide content, or inspection clicks enqueue unchanged positions | [Readable preview levels and read-only gestures](../inbox/2026-10-28-inspector-previews-need-readable-levels.md); inspector-readability browser journey and subgraph preview conservation/component cases |
Expand Down
125 changes: 73 additions & 52 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { NodeQuickEdit, type QuickEditCommit } from './NodeQuickEdit';
import { WorkItem, WorkItemEdge } from '../types/graph';
import { RelationshipType, RELATIONSHIP_OPTIONS, getRelationshipConfig } from '../constants/workItemConstants';
import { useAdaptiveQuality } from '../hooks/useAdaptiveQuality';
import { useItemPresentation } from '../hooks/useItemPresentation';
import { nodeLifeClasses, nodeGlowFilter, isActiveStatus, isCompletedStatus, edgeFlowClass } from '../lib/nodeAnimations';
import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerge';
import type { ObstacleBox } from '../lib/edgeLabelLayout';
Expand Down Expand Up @@ -1381,12 +1382,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i

// A graph owns one simulation. Detached simulations otherwise keep mutating
// shared node objects after a rebuild and can fight the visible simulation.
const [semanticOverviewActive, setSemanticOverviewActive] = useState(false);
const itemPresentation = useItemPresentation(semanticOverviewActive);
const stopActiveSimulation = useCallback(() => {
itemPresentation.clear();
simulationRef.current?.on('tick', null).on('end.persist', null).alphaTarget(0).stop();
simulationRef.current = null;
simulationLayoutScopeRef.current = null;
delete (window as any).__graphPerf;
}, []);
}, [itemPresentation]);

// Initialize empty visualization canvas
const initializeEmptyVisualization = useCallback(() => {
Expand Down Expand Up @@ -3453,7 +3457,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
element.select('.long-press-feedback').remove();
});

const nodePresentationScales = new WeakMap<WorkItem, number>();
let nodePresentationScales = new WeakMap<WorkItem, number>();
const updateNodePresentationFor = (element: SVGGElement, d: WorkItem) => {
const presentedScale = itemPresentationScaleRef.current;
if (nodePresentationScales.get(d) === presentedScale) return;
Expand Down Expand Up @@ -3515,6 +3519,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
};
let displayedPriority: typeof priorityProjection | null = null;
const updateEdgePositions = (forceAvoid = false) => {
if (itemPresentation.isOverview()) return;
if (dotModeRef.current && !forceAvoid) return;
const priority = relationshipPriorityRef.current;
if (displayedPriority !== priority) {
Expand Down Expand Up @@ -3705,6 +3710,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
applyEdgeDisplays();
};
const applyViewportCull = () => {
if (itemPresentation.isOverview()) return;
const svgEl = svg.node();
if (!svgEl) return;
const t = d3.zoomTransform(svgEl);
Expand Down Expand Up @@ -3794,6 +3800,44 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i

// Update zoom with LOD updates
let labelsShownAtScale = liveScale >= 0.6;
const updateZoomPresentation = (scale: number) => {
if (itemPresentation.isOverview()) return;
// Smooth opacity transitions based on zoom level
const getSmoothedOpacity = (threshold: number, fadeRange: number = 0.2) => {
if (scale >= threshold + fadeRange) return 1;
if (scale <= threshold - fadeRange) return 0;
return (scale - (threshold - fadeRange)) / (fadeRange * 2);
};

// Update text opacities with smooth transitions
const activeNodes = nodeElements.filter((d: any) => !cullActive || !d._culled);
activeNodes.selectAll('.node-type-text' as any)
.style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.FAR));
// Title appears across the working band (not only when zoomed all the way
// in) and is counter-scaled to a legibility floor so it stays readable. The
// group transform is updated live here (the only path that runs on zoom).
activeNodes.selectAll('.node-title-text' as any)
.style('opacity', scale >= 0.3 ? 1 : 0);
activeNodes.selectAll('.node-description-text' as any)
.style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.CLOSE));
// Edge labels + arrows follow the edge LOD module (zoom-aware, crowd-aware).
if (labelsShownAtScale !== (scale >= 0.6)) {
labelsShownAtScale = scale >= 0.6;
edgeLabelGroups.style('opacity', labelsShownAtScale ? 1 : 0).style('pointer-events', labelsShownAtScale ? 'all' : 'none');
}
arrowElements.filter((d: any) => edgeDisplay(d) !== 'none')
.style('opacity', (d: any) =>
directionStrategy(scale) === 'hidden' ? 0 : arrowVisibility(scale, d?._pTotal || 1).opacity);
linkElements.filter((d: any) => edgeDisplay(d) !== 'none')
.style('opacity', Math.max(0.1, getSmoothedOpacity(LOD_THRESHOLDS.VERY_FAR, 0.1)))
.attr('stroke-width', function(d: any) {
// Don't override stroke width if this edge has an active dialog
if (editingEdge && editingEdge.edge && editingEdge.edge.id === d.id) {
return 10; // Keep thick for active dialog
}
return ITEM_WIRE_WIDTH;
});
};
zoom.on('zoom', (event) => {
// D3 sets sourceEvent only for direct wheel/pointer gestures. Programmatic
// fit/restore transforms must remain distinguishable so an automatic fit
Expand Down Expand Up @@ -3843,51 +3887,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
(window as any).updateMiniMapViewport(viewportUpdate);
}

// Update LOD based on zoom level
const scale = event.transform.k;

// Smooth opacity transitions based on zoom level
const getSmoothedOpacity = (threshold: number, fadeRange: number = 0.2) => {
if (scale >= threshold + fadeRange) return 1;
if (scale <= threshold - fadeRange) return 0;
return (scale - (threshold - fadeRange)) / (fadeRange * 2);
};

// Update text opacities with smooth transitions
const activeNodes = nodeElements.filter((d: any) => !cullActive || !d._culled);
activeNodes.selectAll('.node-type-text' as any)
.style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.FAR));
// Title appears across the working band (not only when zoomed all the way
// in) and is counter-scaled to a legibility floor so it stays readable. The
// group transform is updated live here (the only path that runs on zoom).
activeNodes.selectAll('.node-title-text' as any)
.style('opacity', scale >= 0.3 ? 1 : 0);
activeNodes.selectAll('.node-description-text' as any)
.style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.CLOSE));
// Edge labels + arrows follow the edge LOD module (zoom-aware, crowd-aware).
if (labelsShownAtScale !== (scale >= 0.6)) {
labelsShownAtScale = scale >= 0.6;
edgeLabelGroups.style('opacity', labelsShownAtScale ? 1 : 0).style('pointer-events', labelsShownAtScale ? 'all' : 'none');
}
arrowElements.filter((d: any) => edgeDisplay(d) !== 'none')
.style('opacity', (d: any) =>
directionStrategy(scale) === 'hidden' ? 0 : arrowVisibility(scale, d?._pTotal || 1).opacity);
linkElements.filter((d: any) => edgeDisplay(d) !== 'none')
.style('opacity', Math.max(0.1, getSmoothedOpacity(LOD_THRESHOLDS.VERY_FAR, 0.1)))
.attr('stroke-width', function(d: any) {
// Don't override stroke width if this edge has an active dialog
if (editingEdge && editingEdge.edge && editingEdge.edge.id === d.id) {
return 10; // Keep thick for active dialog
}
return ITEM_WIRE_WIDTH;
});
updateZoomPresentation(event.transform.k);
});

zoom.on('end.labels', event => {
if (simulationRef.current !== simulation) return;
itemPresentationScaleRef.current = event.transform.k;
updateNodePresentation();
updateEdgePositions(true);
zoom.on('end.labels', () => {
if (simulationRef.current === simulation) itemPresentation.schedule();
});

// Configure simulation for stability. Start hot only if there are
Expand Down Expand Up @@ -3931,10 +3935,28 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
// Fully-pinned graphs don't tick, so without this their labels would stay at
// the default midpoint and could overlap → clean starting positions need it.
runLabelAvoidanceRef.current = () => updateEdgePositions(true);
itemPresentationScaleRef.current = svg.node() ? d3.zoomTransform(svg.node()!).k : 1;
if (cullEnabled) applyViewportCull();
updateNodePresentation();
updateEdgePositions(true);
let itemPresentationPasses = 0;
itemPresentation.bind({
isCurrent: () => simulationRef.current === simulation,
renderItems: resuming => {
if (resuming) nodePresentationScales = new WeakMap();
const scale = d3.zoomTransform(svg.node()!).k;
itemPresentationScaleRef.current = scale;
if (cullEnabled) applyViewportCull();
updateNodePresentation();
updateEdgePositions(true);
updateZoomPresentation(scale);
g.attr('data-presentation-kind', 'items');
g.attr('data-item-presentation-scale', scale);
g.attr('data-item-presentation-passes', ++itemPresentationPasses);
},
presentOverview: () => {
itemPresentationScaleRef.current = d3.zoomTransform(svg.node()!).k;
g.attr('data-presentation-kind', 'overview');
g.attr('data-presentation-scale', d3.zoomTransform(svg.node()!).k);
},
});
itemPresentation.refresh();
setTimeout(() => { if (simulationRef.current === simulation) updateEdgePositions(true); }, 500);
}, [nodes, validatedEdges, handleNodeClick, initializeEmptyVisualization]); // Include handleNodeClick to get fresh connection state

Expand All @@ -3949,10 +3971,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
const simulationRef = useRef<d3.Simulation<any, any> | null>(null);
useLayoutEffect(() => () => stopActiveSimulation(), [layoutScope, stopActiveSimulation]);
const zoomBehaviorRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
const [semanticOverviewActive, setSemanticOverviewActive] = useState(false);
useLayoutEffect(() => {
if (!semanticOverviewActive) runLabelAvoidanceRef.current?.();
}, [priorityProjection, semanticOverviewActive, itemRelationshipScope]);
itemPresentation.refresh();
}, [priorityProjection, itemRelationshipScope, itemPresentation]);
const handleOverviewActive = useCallback((active: boolean) => {
setSemanticOverviewActive(active);
if (active) setSelectedNode(null);
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/components/SemanticGraphOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,11 @@ export function SemanticGraphOverview({
: null,
[traceProjection]
);
useEffect(() => {
useLayoutEffect(() => {
setAutomatic(previous => overviewAtScale(previous, camera.scale));
setAutomaticDepth(previous => semanticDepthAtScale(previous, camera.scale));
}, [camera.scale]);
useEffect(() => {
useLayoutEffect(() => {
onActiveChange(active);
return () => onActiveChange(false);
}, [active, onActiveChange]);
Expand Down
Loading
Loading