diff --git a/.agents/inbox/2026-10-28-clean-layout-needs-clearance-and-topology.md b/.agents/inbox/2026-10-28-clean-layout-needs-clearance-and-topology.md new file mode 100644 index 00000000..85b34c81 --- /dev/null +++ b/.agents/inbox/2026-10-28-clean-layout-needs-clearance-and-topology.md @@ -0,0 +1,79 @@ +# Clean layout needs measured clearance and topology, not only nonintersection + +On2026-10-28 the owner rejected the headed overview/detailed scenes as a junk pile. Labels and pointer hit areas had improved, but overview cards still used16/24px gutters, ports8px from cards and auxiliary lanes6px apart. A no-intersection metric could therefore pass an unreadable view. Detailed items began from a jittered grid or optional dependency rows, which did not place a connected hub at the center of its neighborhood. + +## Evidence and corrective rule + +The initial source audit identified `overviewSlots`, `ports` and the two-mode layout in `InteractiveGraphVisualization`. The correction uses64px overview gutters,20px unrelated-card routing clearance and16px separation between unrelated buses. `overviewOrganization.test.ts` retains a negative with two noncrossing wires6px apart and only4px from a card. It must fail the spacing contract even though exact intersection counts are zero. Positive real-router fixtures must also draw useful routes and conserve every original relationship. + +The new pure organizer splits connected components, deduplicates parallel relationships for degree, places hubs centrally with radial neighbors, follows chains/cycles, and bounds adjacent-ring refinement to40,000 edge evaluations. Its first2k/5k fixture accidentally repeated the same degree-two edges, so it never exercised refinement; replacing those with a backbone plus distinct chords covers the intended network branch. Four topology tests plus existing LOD/component cases passed29/29 in2.69s; three overview spacing/actual-router cases passed3/3 in0.81s. Web types and scoped new-file lint pass. The touched legacy renderer/workspace lint reports0errors and316 warnings in the touched legacy files; that is not a warning-free claim. + +Detailed organization is explicit and persists through the existing bounded save queue. A completely unplaced scene receives topology placement initially; saved scene positions remain authoritative. Organization does not run on pointer/zoom frames. Overview placement uses the represented relationship projection and bounded slot swaps; a dominant hub retains its central slot. + +## Limits and pending acceptance + +Tracked by #233/PR234. Actual headed control/save/reload, final hosted strict screenshots/clearance receipts, independent review and post-dev acceptance are pending. The original localhost graph is preserved; use disposable owned fixtures. Large-data loading, detailed obstacle-aware item routing (#220), hosted parity and release readiness remain separate. CodeGraph ideas are adapted without shared packages or CodeGraph edits. Passing placement geometry does not prove every detailed edge is readable. + +The first actual organizer journey timed out before exercising organization: it looked for the LOD Detail selector on a nine-item graph, but that control intentionally mounts only at40items. The small fixture now asserts its absence and uses the directly rendered detailed view. The failed run/trace is retained; this is a fixture correction, not evidence that the organizer worked. + +The corrected first actual desktop/phone organization/save/reload journey passed2/2 in25.85s, but visual inspection rejected its phone result: fitting all five cards produced short-word fragmentation in narrow compact boxes. This exposed an insufficient acceptance condition (world-space separation and exact persistence alone). The organize action now requests a readable phone zoom floor, and the fixture measures actual projected card width and complete title words after camera paint/settle. Its first stronger probe caught67px before the requested animation; the next check must wait for that specific visible state and verify it survives save/status changes. This final phone correction is still pending; the earlier25.85s run is not final visual acceptance. + +The existing one-time `separateCards` repair remains an exception for already overlapping saved coordinates: it can move those cards and save the repair on load. This predates the topology organizer. Already-clear saved arrangements remain unchanged until an explicit organization action. The original localhost graph is not a fixture. + +## Lifecycle and camera regressions found during review + +Independent review of `2d81ac0` reproduced two lifecycle defects: rebuilding at low zoom could truncate the simulation to viewport-visible items, and a persisted Hierarchical mode could overwrite explicit organization. The correction keeps complete simulation membership (existing paint culling remains), exits the old layout mode on organization, synchronizes its control and checks the current mode again inside the delayed callback. Do not make a display projection the authoritative item set for layout or persistence. + +The first zoom-floor correction referenced a later-declared graph ID during render and hit the error boundary. It now resets against the existing layout scope. A low-zoom native compact-card title edit followed by organization now proves that an offscreen item remains present and receives its saved position. A separate delayed-edge test proves that fresh zero-position items wait for the authoritative connection query before initial organization; the earlier source-only hypothesis omitted the actual loading guard and was not a verified defect. + +The first control-aware camera scorer moved the hub far off-center (desktop 410px, phone 107px) to reduce obstacle overlap, even hiding neighbors. Keep the horizontal hub central and rank fully visible cards before obstacle area, then distance from the preferred center. Bound the vertical adjustment and candidate work. A desktop negative with an unavoidable large obstruction rejects the old strategy of hiding cards. Readability uses at least 0.65 scale after organization; explicit Zoom to fit can still show the whole board. On a phone the readable central neighborhood requires panning to side neighbors rather than shrinking all text. + +Dated validation, 2026-10-29 UTC, Node22 with one headed worker and zero retries: + +- `npm test --workspace @graphdone/web -- src/lib/organizationCamera.test.ts src/lib/topologyOrganization.test.ts src/lib/overviewOrganization.test.ts`: 9/9 in 1.82s, including the 2,000-item/5,000-relationship bounded-work case. +- `npm run typecheck --workspace @graphdone/web`: passed. +- Web-scoped `npx eslint src/lib/organizationCamera.ts src/lib/organizationCamera.test.ts`: passed. A prior root-scoped invocation failed to resolve the legacy root ESLint config; that was not a source lint pass. +- `npx playwright test tests/e2e/graph/topology-organization.spec.ts --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --retries=0 --trace=retain-on-failure`: 4/4 in 42.9s. Covers actual desktop/phone central rings, complete short titles and projected width, full-card/control clearance after acknowledged save, exact original relations and saved reload, low-zoom/offscreen title editing, and fresh unplaced data with delayed edges. Desktop retains all nine readable cards; phone retains the central hub and vertical neighbors with side-neighborhood panning. Final geometry/identity JSON and before/after PNGs are file-backed attachments. + +Earlier failed traces are retained separately. In particular, the temporary multi-line save panel covered a phone neighbor during upload; final visual assertions now wait for the actual acknowledged save status, while API persistence assertions remain independent. This does not establish that transient/error overlays always avoid every item, or that detailed item routes avoid all cards. Full final-source hosted gates and independent review remain required. The owned preview included the independently reviewed PR232 bus interactions; final branch composition must include that now-merged source before acceptance. + + +## Human usability review tightened the acceptance contract + +The first phone acceptance permitted three complete cards from a five-item system. Independent visual review correctly rejected the two clipped peers: tiny graphs need complete context. The organizer now evaluates a bounded set of ring angles/aspects against measured rectangular clearance and available phone space, keeping the four-peer arrangement symmetric. A pure five-card fixture checks readable projected extent, centrality,64px world clearance and deterministic reorder. This uses GraphDone's own card geometry; no shared library. + +Reload checks also needed to wait for the debounced camera write and actual camera framing, then repeat the full visible-scene contract. A desktop native probe found late camera drift because explicit organization marked the camera manual without stopping the existing initial-fit poll; it now marks that graph framed just like the other explicit camera actions. The first stronger reload PNGs still captured entrance opacity: bounds were correct while the hub was invisible. Final checks poll actual effective node/title opacity and keep a separate painted reload PNG. An unrelated earlier 97763da CI failed the new empty catch under `no-empty`; its correction reports a storage warning, and full touched-renderer lint has0errors/317 existing warnings. + +The focus card now clears obstacles before maximizing complete neighbor count. A helper-only tall-overlay negative proves that the old count-first scorer could leave the hub fully covered; this was not claimed as a native UI reproduction. Ordinary transient upload progress is excluded from permanent organization framing so its shrinking panel cannot push the finished ring into top controls. Error/attention panels retain obstacle status and recovery actions; four save-status unit cases pass in1.02s. + +On2026-10-29, `topology-organization.spec.ts` with one headed worker/zero retries passes4/4 in45.7s after these changes. It requires all nine desktop/all five phone cards, complete title words, actual paint,12px screen/control margins, readable hub width, original relationships, acknowledged API positions, stable saved/reloaded screen bounds (within0.5px), offscreen edits and delayed fresh edges. Eleven focused topology/overview/camera units pass in1.59s, web types pass, and full touched-file lint passes with the legacy warnings explicit. Fresh PNG/JSON pairs are retained separately from every failed probe. Hosted exact-source acceptance and independent review are still pending. + +Evidence boundary correction: the earlier301-item51.2s journey exercised the new spacing runtime and interaction/conservation, but did not call the quantitative `inspectSchematicOverview` clearance helper. Only the nested and large-symbol journeys did. Do not describe that old301 run as measured64/20/16 clearance acceptance. The next overview chunk adds explicit external shown/total counts, coverage receipts and at-rest quantitative checks to the301 journey. + +## Refinement must preserve the placement invariant + +Independent review of8516bc5 found a new compact-phone defect: initial placement used each card's rectangle, but subsequent edge-length swaps assumed interchangeable slots. A six-item/nine-link dimension fixture reduced minimum clearance to9px. A minimal five-item fixture using actual `graphItemLayout` with its shipped fallback text measurement reproduced64.000027px before refinement and9.000027px afterward; the long nested cards are320×174. This is a verified pure production-function defect, not a claim about a native browser scene. + +The retained six-item regression fails against8516 (five pass, one fail). The correction checks both moved rectangles against every component card before accepting a compact-ring swap, including the hub. It counts/reports rectangle evaluations and rejected swaps and caps rectangle work at40,000, independently of the existing40,000 edge-evaluation cap; the compact search itself is limited to eight peers. Actual measured-title/nested-card and heterogeneous-dimension controls both retain at least64px after refinement. `npm test --workspace @graphdone/web -- src/lib/topologyOrganization.test.ts`:7/7 in0.813s on2026-10-29 UTC. Preserve spacing as a hard invariant throughout optimization; a shorter wire is not improvement if it makes cards crowd or overlap. + +## Readability receipts must show the coverage tradeoff + +Independent usability review found the old “269 not drawn” label mixed internal links with external deferral and could imply that “Links: All” meant every external wire was painted. The visible dock now reports drawn/eligible external connections separately from inside-group links and offers Browse all with the full original count. Coverage receipts retain external/pair denominators, deferred/priority/internal/pending partitions and drawn connected-group counts. No universal high coverage percentage is asserted; an empty view still cannot pass. + +The first added301 clearance call used `.edges` on the old item-only snapshot and failed before the new helper executed. It now uses the separately queried original edge list. The corrected desktop/phone301 journey passes2/2 in~90s with one headed worker/zero retries. Unlike the earlier51.2s case, this executes quantitative clearance checks and retains at-rest PNG/geometry before input inspection. Desktop:361 external,101 drawn,260 deferred,9 internal,38 eligible directed pairs/13 drawn, all8 displayed groups connected by drawn routes; minimum card/wire clearance20/24px and zero card intersections/unrelated crossings/overlaps/diagonals. Phone:2 external connections drawn from361,1/38 pairs,both displayed groups connected; card clearance127px and unrelated-wire clearance null because there is only one bus. Full directed originals remain in Browse. Component/overview units pass12/12 in2.37s; web types and touched coverage-file lint pass. Independent visual review accepts the readability and truthful accounting; these fixtures do not establish that every subset meets every task. + +That review also identified avoidable phone blank space: the fixed160px grid phase can discard a valid preceding row rather than shifting the grid below controls. The current two-card phone view is not an intrinsic capacity limit. Improving obstacle-aware slot offsets remains the next local spatial correction before final acceptance. + +## Measured controls should shift the grid, not waste a readable row + +The retained phone obstacle fixture fails against0b6b532 with two slots where three fit. The slot calculation now evaluates at most32 deterministic vertical phases derived from actual control boundaries, preserving the existing centered columns,96px card height,64px gutters and20px obstacle clearance. Placement and admission capacity use the same selected phase; tie cases retain the existing phase to minimize churn. This is screen-space projection only, with no canonical position write. + +The negative/geometry/component suite passes31/31 in2.45s after the correction. Actual301-item phone journey passes1/1 in40.7s with a single headed worker/zero retries: three complete240×96 cards at y342/502/662 instead of two at492/652,45/361 external originals instead of2/361,4/38 directed pairs instead of1, and all three displayed groups connected by drawn routes. Minimum unrelated-card/wire clearance is20/96px, with zero clutter intersections/overlaps/crossings/diagonals. At-rest PNG/geometry and subsequent actual interaction/no-write checks are retained. The fixture now protects all displayed groups and the observed minimum pair coverage (desktop13,phone4) explicitly, rather than allowing spacing to silently suppress useful routes. These are fixture baselines, not universal percentage promises. + +## Explicit organization must publish navigation state after one-shot layout + +Review of6ba55f2 found a minimap lifecycle defect. The exact reset/startup/tick/end callbacks with real d3-force moved the pinned nodes but left the old minimap coordinates after its five800ms startup polls had finished. Organization restarts at alpha0 and emits only one tick, so it cannot rely on an every-eighth-tick publish. Position-only save updates do not reinitialize the renderer or restart the finite polling effect. + +A native desktop regression waits until the initial minimap is populated and the finite startup window has ended, then wraps the existing receiver, clicks the actual Organize action and compares every independently read organized card coordinate with the received minimap positions. It fails against6ba; the failed headed trace is retained. The correction emits a scoped organization event and the existing minimap publisher handles it immediately, including type data, without invoking camera refit or restoring continuous idle ticks. Closed-map reopening keeps its existing snapshot path. + +On2026-10-29 the corrected actual desktop journey passes1/1 in19.3s with one headed worker/zero retries, preserving painted/readable/all-nine-card, original-edge, API save/reload and camera checks as well as the new minimap coordinate receipt. The phone case remains immediate-action coverage while desktop exercises delayed action after startup. Exact-source review/final composition/hosted acceptance remain required; earlier6ba gates are superseded by this source correction. diff --git a/.agents/inbox/2026-10-29-high-degree-detail-needs-local-lod.md b/.agents/inbox/2026-10-29-high-degree-detail-needs-local-lod.md new file mode 100644 index 00000000..1c713830 --- /dev/null +++ b/.agents/inbox/2026-10-29-high-degree-detail-needs-local-lod.md @@ -0,0 +1,69 @@ +# A settled graph is not useful when every neighbor is off screen + +On 2026-10-29 the owner reported that Essential/Items around EPIC2215 still showed a fan of lines with no readable endpoints. This happened in the owned301-item/370-relationship copy used for headed review, after explicit organization; the original localhost graph was not changed. + +## Evidence + +The actual headed DOM/data receipt `epic-2215-detail-failure.json` and screenshot `owned-301-organized-detail.png` show107 distinct incident neighbors,107 painted incident paths,zero visible neighbor cards andzero labels. All107 are DEPENDS_ON,106 outgoing/one incoming. The graph reports atRest/cameraReady/cameraFramed true and zero card overlaps. Its root is EPIC2215, “Lane7 — Factory: session loops, core tooling, automation hygiene”. The camera scale is1.2125732532. The canonical organizer performed11,384 edge evaluations, but put a large first ring far outside the readable viewport. These are actual owned-copy observations, not a claim that production/original data was mutated. + +Essential priority is not defective here: all107 endpoint pairs need their available fallback. Uniform global rings plus a readable zoom floor cannot make107 full cards fit at once. Readiness/no-overlap metrics and small star fixtures therefore miss the owner's actual problem. + +## Corrective contract + +Detailed Items mode needs a bounded local cut: the original focus, nearby readable original-item representatives, and semantically named overflow summaries/buses. Every painted route ends on a painted original card or summary. Replaced native cards/paths/hitboxes/arrows/labels must share presentation ownership; no offscreen wire fan may remain underneath. Preserve every directed/type/parallel original in disjoint accounting and the complete relationship browser. Missing endpoints and self relationships stay explicit. Priority suppression and spatial aggregation are separate counts. + +Projection coordinates belong to the display layer, never simulation nodes/fx/fy/position receipts. Projected cards open the original item's existing inspector; editing saved board positions explicitly returns to canonical layout first. Hold focus/page/filter through small pans and zooms; transform the accepted projection rather than rerouting every frame. Recompute live semantic memberships after actual data changes. Auto overview, manual Items, paging, Back, resize and reload need useful stable behavior. + +## Validation and limits + +The first integrated acceptance must reproduce the107-neighbor failure with visible readable original neighbors, bounded directed routes, meaningful semantic overflow, exact original accounting and no view-induced mutations. Retain the owned301 example as human evidence, a small deterministic high-degree fixture for serial local work and the existing hosted2,000-item/5,000-relationship gate for scale. Small5/9-node direct graphs must remain fully direct. Actual pointer/keyboard/original-item editing, effective paint, screen/control margins, routing work and camera continuity are required; a screenshot of a clean unrelated star cannot substitute. + +Tracked in233/PR234 and detailed wiring220. The topology/minimap foundation remains reviewable, but does not complete dense detailed LOD by itself. General arbitrary item routing and broad hosted parity remain separate from the first focused-neighborhood slice. CodeGraph ideas only, no shared library. + +## Bounded model and geometry checkpoint + +The pure `projectItemNeighborhood` model admits original refs plus live semantic summaries, caps the cut at seven cards, preserves unique deterministic paging and emits exact directed/type/parallel original IDs for bounded schematic routing. Its partitions distinguish pre-router represented candidates, priority-summarized, pending, self/internal and spatially deferred edges. A two-card phone cut retains a real neighbor; all107 originals remain accessible across pages. Directional representatives preserve incoming-parent/outgoing context when at least two real-neighbor slots fit, ranked by that direction's eligible relationship priority. The pre-correction direction cases fail3/16; corrected16/16 pass in0.385s. Group filters do not silently pull in outside-group parents or bypass suppressed reverse edges. + +The separate display-layout helper uses measured14/18px card geometry,20px viewport/control margins,64px card gutters and at most96 placement candidates. It never changes canonical positions. Its first adversarial activation check failed because unknown endpoints could create a false dense focus; activation now counts only loaded neighbors while pending originals remain in the model's denominator. Exact displayed-edge selections resolve through the existing original relationship browser without inventing group endpoints. + +Combined pure-model/layout/current-original selection and save-status controls pass39/39 in1.73s on2026-10-29, with scoped new-file lint passing. This is not browser integration acceptance. The first actual desktop integration of the owned301 case shows the focus, four real linked originals and two named semantic summaries; the first phone pass exposed wrapped controls and a persistent acknowledgment wasting the second-card area. Compact navigation plus expiry of successful acknowledgment now admits focus and one full original neighbor. Error/unsent/cleanup states remain visible. Full no-write, editing, routing geometry, pan/zoom/reload and scale acceptance are still pending. + +## First integrated owned-copy observations + +The new local renderer is separate from canonical simulation coordinates and hides the replaced native card/path/hitbox/arrow/label layers together. The existing inspector opens original IDs; local focus changes pan the canonical camera rather than save temporary display positions. Paging, direction-aware real-neighbor admission, named summaries and exact selected-edge browser scopes use the pure model. Detail mode and local focus/page/group/history preferences use the existing owner+graph layout scope. Normal successful layout acknowledgment expires after3.5s; error/unsent/receipt-recovery states remain visible. + +Actual headed checks on the owned EPIC2215 copy show four real neighbors plus two semantic groups on desktop, and the focus plus one readable real neighbor on390×844 phone. The real card opens the original inspector and returns to the local view. Focus-neighbor→Back preserves the exact camera and every canonical simulation coordinate; paging changes the real neighbor, and a clean reload retains Items mode and that neighbor, with zero page errors. These are manual owned-copy checks, not final automated acceptance. + +Two transient `onBoard is not a function` errors were observed while copying a new required-prop component before its caller during multi-file hot refresh. The mixed-version errors are retained separately. Loading the coherent full source and cleanly reloading has zero page errors. Future required-prop preview handoffs should copy while the page is on a blank owned tab, then reload; a half-updated HMR session is not a valid source acceptance run. + +The owner's subsequent phone feedback calls for a bottom menu that fits the screen. The current local footer/navigation correction is full-width, keeps equal-width app tabs and44/48px touch targets, and preserves the real-neighbor area. Exact pixel/keyboard/editor/no-write, pan/zoom, cold reload, responsive safe-area and hosted scale regressions remain required before integration. The new renderer is still draft work, and prior8698/6ba hosted results do not accept this larger composition. + +## Explicit navigation and live filters must remain recoverable + +Independent review of1669086 reproduced two navigation blockers: returning from overview search could retain an old local focus still visible on screen; removing/renaming a selected group could hide the only reset when one group remained. The pre-fix React controls fail2/3. Explicit navigation now carries a distinct sequence, including repeated navigation to an already inspected ID; opening a neighbor inspector preserves the working local focus. A persisted missing filter always exposes both the group selector and a direct reset. Four local interaction controls and three inspector controls pass; inspector/quick-editor closure restores the original projected trigger rather than a hidden native SVG trigger. The quick editor anchors to the actual displayed original card. + +The phone card header/padding now uses152px for a three-line18px title, retaining44px actions and14px supporting text. A clean composed preview at390×811 has two320×152 original cards,64px between them and20px above the full-width73px footer; the original107 relationships remain explicit. The first synthetic safe-area check incorrectly assumed taller controls than the actual154px toolbar/73px footer; its failure is retained, and the corrected actual-geometry check passes alongside the navigation/inspector controls17/17. This is a shorter-viewport check, not device-specific safe-area emulation. Final browser, scale and exact-source review remain pending. + +## Consume navigation intent before remounting a graph + +A fresh independent recheck of4ee5120 reproduced another lifecycle bug: after overview navigation toB, local navigation to b0 and a keyed graph/account remount, the parent retained the old navigation request while the child forgot it had handled that request. Remount replayedB and overwrote persisted b0. Explicit requests now have a monotonically increasing sequence, are acknowledged/cleared once handled and are bound to the current owner+graph scope; a scope change clears any outstanding request. A controlled remount regression also verifies that a second explicit navigation to the same item still works after acknowledgment. This corrects navigation ownership; persisted local focus/history remain separate user preferences. + +The107-neighbor serial headed acceptance passes desktop/phone2/2 in45.6s, including an intentionally invisible-wire negative control, actual hover/keyboard original IDs, exact camera Back/reload, real original-title editing and no unexpected mutations/errors. All source geometry/metadata/edges stay unchanged. Phone tabs measure130/130/130px across390px,50px high, with no overflow; the connection dock is flush with the canvas bottom. These current small-fixture receipts do not substitute for the new dense phase in hosted2k/5k acceptance. + +## Reserve title space for the actual displayed page + +A narrow-page regression found60px card clearance where64px is required: capacity was estimated from a seven-card desktop page, while the actual phone page selected a different original with a longer three-line title. Estimating from that unrelated page could therefore under-reserve the card envelope. The local slot cell now reserves the fixed maximum152px three-line envelope; each actual painted card still uses only its measured title/type/status height. This removes an unnecessary second incident-model projection, bounds title work to the displayed cut and prevents page-dependent clearance loss. The failing60px control is retained; corrected controller geometry must pass before final acceptance. + +## Coherent local acceptance checkpoint + +On2026-10-29 the coherent108f2d3 runtime passes the two high-degree original-item journeys and four organization/save/reload/offscreen/delayed-edge cases6/6 in90.16s, one headed worker/zero retries. The bounded model/layout/selection/controller/inspector controls pass44/44 in3.26s; web types pass. Scoped lint caught an unsupported DOM callback type name in the new inspector test even though TypeScript accepted it; the test now uses an explicit timestamp callback type, with scoped lint zero errors/two preexisting inspector-any warnings. No timing gate or visual-clearance threshold was relaxed. The heavier hosted2k/5k dense-local phase, final-source review and branch/runtime/post-dev gates remain pending. + +## Acceptance must target actual native classes and compact labels + +Independent test review of108f2d3 found the native-layer guard used nonexistent `.clickable-edge`/`.edge-arrow` selectors instead of the renderer's actual `.edge-clickable`/`.arrow`. Product CSS hides their groups, but the test could miss an exposed hitbox or arrow. Use actual renderer classes, require nonempty native-family inventories and retain controlled exposure negatives before claiming all source interaction/presentation layers are hidden. API snapshots and mutation guards also do not establish unchanged in-memory simulation fields; compare actual original-node x/y/fx/fy as separate evidence. + +Comprehensive40825745043 at23c3d1f failed on bothNode22.18/24 because the existing compact mobile storage test expected “Sync paused” after the intentional footer label became “Paused”. The full accessible description still names sync. The corrected test requires exact compact “Paused”, the unchanged full accessible label and expanded “Saved locally · sync paused”. This is an expectation correction plus stronger state coverage, not a product retry or relaxed readability gate. The failure log is retained as23c3-comprehensive-failure.log. + +Independent review of the six-case PNG archive also rejected the offscreen-organization screenshot: persistence had completed, but the camera's scheduled fit had not, so the receipt had cameraReady=false and two tiny blank cards. This was an evidence timing defect, not a verified failure of the eventual product layout. That case must now wait for actual camera readiness and pass the existing painted-symbol geometry check with both original IDs/full titles before saving its final screenshot. Retain the intermediate frame and do not present database success as visual acceptance. + +The small local browser journeys now exercise the same zoom-readiness function used by the hosted scale gate. Its first actual phone control failed after15s even while the card/wire view was readable: the shared visibility predicate required both SVG bounding-box dimensions to be positive, excluding a valid vertical local wire. The corrected wire-only check accepts positive width or height, while cards still require both dimensions, readable title paint and viewport containment. The negative trace is retained aslocal-zoom-axis-negative; no product rendering or five-second CI budget changed. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index c36392ac..bb63072d 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,8 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| A dense item shows a fan of essential wires while every neighbor is off screen, despite settled/no-overlap metrics | [Detailed LOD needs a readable local cut](../inbox/2026-10-29-high-degree-detail-needs-local-lod.md); EPIC2215 owned301 failure and local-neighborhood acceptance | +| Overview has no intersections but wires hug cards/each other, or hubs are flattened into rows | [Measure clearance and organize by topology](../inbox/2026-10-28-clean-layout-needs-clearance-and-topology.md); topology/overview organization tests, real control/save journey and strict schematic clearance | | 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` | diff --git a/docs/architecture/local-item-lod.md b/docs/architecture/local-item-lod.md new file mode 100644 index 00000000..78109240 --- /dev/null +++ b/docs/architecture/local-item-lod.md @@ -0,0 +1,13 @@ +# Readable local detail for dense connected items + +Items mode must remain useful around a high-degree focus. EPIC2215 exposes the failure:107 essential neighbors,107 long wires,zero visible neighbors. Showing every full item at a global ring radius cannot satisfy readable detail. + +A local projection retains the original focus and a bounded page of real neighbors. Use current semantic paths to summarize overflow, separate incoming/outgoing buses and preserve every original directed/type/parallel relationship. The existing relationship-priority projection chooses represented types per original pair; spatial aggregation then makes that representation fit. Counts distinguish represented, priority-summarized, spatially aggregated/deferred, self/internal and missing-endpoint relationships. Browse always resolves current original IDs. + +Activation is automatic for a visible dense focus in Items mode. Explicit Focus or overview/search navigation chooses the local focus; opening an original inspector or inspecting a relationship preserves the working cut. Otherwise use the relevant dense item near the viewport center. Retain that focus and deterministic page/group selection through small camera changes. A focus change, meaningful viewport exit, resize or actual data change can refresh the cut. Small direct graphs retain the native renderer. Auto overview continues to own low-zoom summaries; manual Items still uses readable local detail when needed. + +The local layer owns separate screen positions. It never changes canonical node positions, simulation membership or saved layout. Projected item cards open the existing original-item inspector. An explicit saved-position edit returns to the canonical board before dragging. Pan translates the accepted view and zoom controls its LOD transition; pointer hover and ordinary camera frames do not rebuild routes. Navigation/Back restores focus, page, group filter and camera; reload derives the useful cut without saving positions again. + +Cards use measured title/type/status dimensions and14/18px typography. Control geometry determines capacity, with a small hard cap; admit at least one real neighbor when two cards fit, and show named overflow/paging access honestly. Use the in-repo schematic router and accessible bus interaction layer. Every painted endpoint is a painted card/summary, with generous card/wire/control clearance and meaningful visible connection coverage. Phone geometry may require a smaller page, never microscopic text. + +Validate the actual owned301 case, deterministic107-neighbor/incoming/reverse/parallel/self/missing/live-membership cases, actual editor/bus/keyboard behavior, no-mutation snapshots and hosted2k/5k scale. Report bounded admission/routing work and every deferred original. Keep the earlier failing headed evidence. Do not call233/220 complete while this focused dense view is still a fan of offscreen wires. diff --git a/docs/architecture/topology-organization.md b/docs/architecture/topology-organization.md new file mode 100644 index 00000000..46efa6fe --- /dev/null +++ b/docs/architecture/topology-organization.md @@ -0,0 +1,31 @@ +# Organize by connections and leave room to read + +Owner feedback on2026-10-28: overview remains crowded despite readable labels and working hover; detailed mode remains a junk pile. A dominant hub should occupy the center of its neighbors, with circular arrangements where the graph calls for them. This implementation is tracked by #233, complements detailed routing #220 and does not establish complete LOD acceptance by itself. + +## Spatial contract + +Use actual card sizes. Reserve at least64px between overview columns and64px between rows, and at least20px of routing clearance around unrelated cards. Wire lanes from unrelated buses must have a measurable separation, not just avoid exact intersections. Keep a complete relationship browser and explicit deferred counts; passing by hiding all useful routes is forbidden. Phone layouts keep readable cards and fewer representatives, not scaled-down text. + +## Topology and bounded refinement + +Compute undirected connectivity for placement while retaining every original directed relationship for rendering/inspection. Parallel or redundant types cannot multiply a node's centrality. Split disconnected components. A dominant connected hub occupies the center; its neighbors occupy angular sectors around it. Chains and cycles follow their own ordered path/ring. Deeper networks use bounded radial breadth levels with related branches adjacent, with radii derived from measured dimensions and spacing. Pack components without overlap. Use deterministic IDs/ties and bounded iterative ordering, report work, and avoid all-pairs force work on every animation tick. + +Overview admits readable representatives through its existing hierarchy/parent fallback and full lists, then iteratively improves slot assignment using its actual represented relationships. Preserve a central slot for a clear hub where space permits. Camera gestures and hover retain accepted positions. + +Detailed organization uses the existing explicit organize/save lifecycle. Saved layouts stay authoritative on load. A genuinely unplaced scene can use the topology organizer initially; adding items must not reposition every existing item. The original localhost graph is not a test fixture. + +## Validation and limits + +Keep small deterministic hub, chain, cycle, disconnected, long-card and phone cases; reject a deliberately crowded negative. Inspect real browser screenshots and geometry from the app, including exact connection coverage, card/wire clearance, endpoint identity, edits, saved reload and unchanged camera during inspection. Extend the existing2,000-item/5,000-relationship hosted gate; larger capacity and hosted parity remain separate. + +CodeGraph's circle packing, per-region admission, parent fallback and stable renderer/camera identity inform these choices. GraphDone's rectangular cards, directed task relationships and saved layouts need an independent implementation; no shared library or CodeGraph change. The existing orthogonal overview router is a useful bounded reference. Detailed item routing around arbitrary cards remains #220 and cannot be claimed solved by radial placement alone. + +The existing one-time `separateCards` repair remains an exception for already overlapping saved coordinates: it can move those cards and save the repair on load. This predates the topology organizer. Already-clear saved arrangements remain unchanged until an explicit organization action. The original localhost graph is not a fixture. + +Organization retains every simulation item even when only a viewport subset is painted. It explicitly exits the legacy dependency-row mode, including delayed callbacks. The camera keeps the selected component's hub horizontally centered, first keeps the focused hub clear of controls, then ranks complete visible cards before other control overlap, and uses a readable zoom floor for the central neighborhood. Small phone hubs choose from bounded angle/aspect candidates using measured rectangular clearance, so a five-item system keeps all five cards readable. Larger neighborhoods may require panning; explicit Zoom to fit remains available. Camera framing examines at most32 nearby cards and16 vertical candidates. Save-status transitions are separate from final acknowledged-save screenshot acceptance; transient/error overlays are not claimed solved here. + +Final small-scene visuals require every card and title actually painted, at least12px of screen/control margin, complete short words, full nine/five-card desktop/phone containment, central hub, exact saved positions and settled camera restoration. A correct bounding box during an entrance fade is not visual acceptance. Normal transient save progress is excluded from permanent organized framing; attention/error panels remain obstacles. Explicit organization stops the initial camera-framing loop. + +Compact heterogeneous slots are not interchangeable. Every proposed compact-ring swap revalidates both moved cards against all component rectangles before accepting its connection-length improvement. Rectangle evaluations and rejected swaps are reported, with a separate40,000 evaluation cap. Long-title/nested-card cross-link negatives retain the initial64px clearance after refinement. + +After explicit organization, publish the new complete positions/types through the existing minimap publisher immediately. A pinned one-shot simulation emits too few ticks to rely on periodic tick publication, and finite startup polling may already have ended. Navigation state is derived display state; publishing it must not reframe the camera or restart idle physics. diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index c36d419d..d5dd59da 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -61,6 +61,9 @@ import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { graphItemLayout, measureGraphText, edgeLabelDimensions, GRAPH_FONT_FAMILY, ITEM_TITLE_SIZE, ITEM_TITLE_LINE, ITEM_HEADER_HEIGHT, EDGE_LABEL_SIZE, ITEM_WIRE_WIDTH } from '../lib/graphSymbolLayout'; import { placeItemLabels } from '../lib/edgeLabelPlacement'; import { separateCards } from '../lib/separateCards'; +import { organizeTopology } from '../lib/topologyOrganization'; +import { LocalItemNeighborhood } from './LocalItemNeighborhood'; +import { frameOrganizedNeighborhood } from '../lib/organizationCamera'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; import { computeCompactLayout, isHierarchicalLayout, parseLayoutMode, LAYOUT_MODE_STORAGE_KEY, type LayoutMode } from '../lib/layoutAlgorithms'; import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry'; @@ -461,7 +464,42 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const [itemLabelCount, setItemLabelCount] = useState(0); const [itemRelationshipScope, setItemRelationshipScope] = useState(null); const itemRelationshipTriggerRef = useRef(null); - const itemPairTriggerRef = useRef(null); + const itemPairTriggerRef = useRef(null); + const [localItemActive, setLocalItemActive] = useState(false); + const [localFocusRequest, setLocalFocusRequest] = useState<{id:string;sequence:number;scope:string|null}>(); + const localFocusSequenceRef = useRef(0); + const requestLocalFocus = useCallback((id:string)=>setLocalFocusRequest({id,sequence:++localFocusSequenceRef.current,scope:activeLayoutScopeRef.current}),[]); + const acknowledgeLocalFocus = useCallback((sequence:number)=>setLocalFocusRequest(previous=>previous?.sequence===sequence?undefined:previous),[]); + const localItemActiveRef = useRef(false); + const handleLocalActive = useCallback((active: boolean) => {localItemActiveRef.current=active;setLocalItemActive(active);},[]); + const localItemBoardRef = useRef(false); + const handleLocalBoard = useCallback((active: boolean) => { localItemBoardRef.current = active; }, []); + const [localItemControls, setLocalItemControls] = useState(null); + const [localItemFooter, setLocalItemFooter] = useState(null); + const browseLocalRelationships = useCallback((scope: RelationshipScope, trigger: HTMLElement | SVGElement) => { + itemPairTriggerRef.current = trigger; + setItemRelationshipScope(scope); + }, []); + const inspectLocalItem = useCallback((id: string) => { + const item = simulationRef.current?.nodes().find(node => node.id === id); + if (!item) return; + const focusId = containerRef.current?.querySelector('[data-local-focus]')?.dataset.localFocus; + const triggers = Array.from(containerRef.current?.querySelectorAll('[data-local-open]') ?? []); + (triggers.find(button => button.dataset.localOpen === id) ?? triggers.find(button => button.dataset.localOpen === focusId))?.focus({preventScroll:true}); + setSelectedNode({ ...item }); + setSelectedNodes(new Set([id])); + setEditingEdge(null); + }, []); + const navigateLocalItem = useCallback((id: string, saved?: {x:number;y:number;scale:number}) => { + const item = simulationRef.current?.nodes().find(node => node.id === id); + const svg = svgRef.current, container = containerRef.current, zoom = zoomBehaviorRef.current; + if (!item || !svg || !container || !zoom) return; + cameraManuallyPositionedRef.current = true; + if (currentGraphIdRef.current) fittedGraphsRef.current.add(currentGraphIdRef.current); + const k = saved?.scale ?? Math.max(0.65, d3.zoomTransform(svg).k); + const transform = d3.zoomIdentity.translate(saved?.x ?? container.clientWidth / 2 - item.x * k, saved?.y ?? container.clientHeight / 2 - item.y * k).scale(k); + d3.select(svg).interrupt().transition().duration(450).call(zoom.transform, transform); + }, []); const closeItemRelationships = useCallback(() => { const trigger = itemPairTriggerRef.current; itemPairTriggerRef.current = null; @@ -501,6 +539,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i requestAnimationFrame(() => { const active = document.activeElement; if (active && active !== document.body) return; + const focusId = containerRef.current?.querySelector('[data-local-focus]')?.dataset.localFocus; + const projected = Array.from(containerRef.current?.querySelectorAll('[data-local-open]') ?? []); + const localTrigger = projected.find(button => button.dataset.localOpen === returnFocusId) ?? projected.find(button => button.dataset.localOpen === focusId); + if (localTrigger) {localTrigger.focus({preventScroll:true});return;} const trigger = Array.from( containerRef.current?.querySelectorAll('.node-edit-icon') ?? [] ).find(element => element.dataset.nodeId === returnFocusId); @@ -511,6 +553,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (!quickEditRequest) return; setInlineEdit(null); setExpandedNode(null); + quickEditReturnFocusIdRef.current = quickEditRequest.id; setQuickEditNode(quickEditRequest); onQuickEditRequestHandled?.(); }, [quickEditRequest, onQuickEditRequestHandled]); @@ -796,6 +839,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const layoutScope = currentUser && currentGraph ? JSON.stringify([currentUser.id, currentGraph.id]) : null; const activeLayoutScopeRef = useRef(layoutScope); activeLayoutScopeRef.current = layoutScope; + useLayoutEffect(() => setLocalFocusRequest(undefined), [layoutScope]); const simulationLayoutScopeRef = useRef(null); const positionSaveQueueRef = useRef(positionSaveQueue); positionSaveQueueRef.current = positionSaveQueue; @@ -1383,7 +1427,7 @@ 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 itemPresentation = useItemPresentation(semanticOverviewActive || localItemActive); const stopActiveSimulation = useCallback(() => { itemPresentation.clear(); simulationRef.current?.on('tick', null).on('end.persist', null).alphaTarget(0).stop(); @@ -1429,7 +1473,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Create zoom behavior for empty canvas with reversed wheel direction const zoom = d3.zoom() - .filter(event => !(event.target as Element)?.closest('[data-lod-controls]') && (!event.ctrlKey || event.type === 'wheel') && !event.button) + .filter(event => !(event.target as Element)?.closest('[data-lod-controls]') && (event.type === 'wheel' || !(event.target as Element)?.closest('[data-local-item-card]')) && (!event.ctrlKey || event.type === 'wheel') && !event.button) .scaleExtent([0.1, 3]) .wheelDelta((event: WheelEvent) => { // Standard direction (like maps/most apps): scroll UP (deltaY<0) zooms IN. @@ -1659,16 +1703,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // strips physics keys (fx/fy) to preserve live velocity, so placed nodes // arriving via a graph-switch or poll would otherwise come back unpinned // and drift. Snapshot-authoritative load must hold here too. - if (!layoutReflowingRef.current) { - (nodeMerge.nodes as any[]).forEach((node: any) => { + (nodeMerge.nodes as any[]).forEach((node: any) => { const placed = !(((node.positionX ?? 0) === 0) && ((node.positionY ?? 0) === 0)); if (placed && !node._dragging) { node.fx = node.positionX; node.fy = node.positionY; node.userPinned = true; } - }); - } + }); simulation.nodes(nodeMerge.nodes as any); const linkForce = simulation.force('link') as d3.ForceLink; if (linkForce) { @@ -1775,7 +1817,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Create or reuse zoom behavior with reversed wheel direction const zoom = d3.zoom() - .filter(event => !(event.target as Element)?.closest('[data-lod-controls]') && (!event.ctrlKey || event.type === 'wheel') && !event.button) + .filter(event => !(event.target as Element)?.closest('[data-lod-controls]') && (event.type === 'wheel' || !(event.target as Element)?.closest('[data-local-item-card]')) && (!event.ctrlKey || event.type === 'wheel') && !event.button) .scaleExtent([0.1, 4]) .wheelDelta((event: WheelEvent) => { // Standard direction (like maps/most apps): scroll UP (deltaY<0) zooms IN. @@ -1913,6 +1955,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // never-placed nodes are seeded near center and left free to flow. // (This block used to null every node's fx/fy unconditionally, which is // why arrangements never survived a reload — the real drift bug.) + const initialOrganization = nodes.length && nodes.every(node => !(node.positionX || node.positionY)) + ? organizeTopology(nodes, validatedEdges as any[], node => graphItemLayout(node, measureGraphText), {width, height}) + : null; const spreadCols = Math.max(1, Math.ceil(Math.sqrt(nodes.length))); const spreadSpacing = 260; // > collision diameter so the spread has no overlaps const spreadHalf = (spreadCols * spreadSpacing) / 2; @@ -1927,13 +1972,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i node.fx = node.positionX; node.fy = node.positionY; } else { - node.userPinned = false; - node.fx = null; - node.fy = null; - // Clean grid spread (not a random pile) so physics refines from a - // non-overlapping start. - if (!node.x) node.x = (i % spreadCols) * spreadSpacing - spreadHalf + ((i * 13) % 23) - 11; - if (!node.y) node.y = Math.floor(i / spreadCols) * spreadSpacing - spreadHalf + ((i * 7) % 19) - 9; + const point = initialOrganization?.positions.get(node.id); + node.userPinned = Boolean(point); + node.fx = point?.x ?? null; + node.fy = point?.y ?? null; + if (point) { + node.x = point.x; + node.y = point.y; + } else { + if (!node.x) node.x = (i % spreadCols) * spreadSpacing - spreadHalf + ((i * 13) % 23) - 11; + if (!node.y) node.y = Math.floor(i / spreadCols) * spreadSpacing - spreadHalf + ((i * 7) % 19) - 9; + } } }); @@ -1948,16 +1997,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } }); - // Viewport culling - only render visible nodes for performance - const margin = 200; - const visibleNodes = (currentTransform?.k || 1) < LOD_THRESHOLDS.VERY_FAR ? - nodes.filter((node: WorkItem) => { - const nodeX = (node.positionX || 0) * (currentTransform?.k || 1) + (currentTransform?.x || 0); - const nodeY = (node.positionY || 0) * (currentTransform?.k || 1) + (currentTransform?.y || 0); - return nodeX >= -margin && nodeX <= width + margin && - nodeY >= -margin && nodeY <= height + margin; - }) : nodes; - + const visibleNodes = nodes; + // Simple 2D force simulation const simulation = d3.forceSimulation(visibleNodes as any); simulationLayoutScopeRef.current = activeLayoutScopeRef.current; @@ -3697,7 +3738,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const CULL_MIN_SCALE = 0.5; let cullCounter = 0; let cullActive = false; - const edgeDisplay = (d: any) => d._priorityVisible === false || !d._route || (cullActive && d.source?._culled && d.target?._culled) ? 'none' : null; + const edgeDisplay = (d: any) => { + if (d._priorityVisible === false || !d._route || (cullActive && d.source?._culled && d.target?._culled)) return 'none'; + if (localItemBoardRef.current) { + const camera = d3.zoomTransform(svg.node()!); + const visible = (node: any) => node.x * camera.k + camera.x >= 0 && node.x * camera.k + camera.x <= width && node.y * camera.k + camera.y >= 0 && node.y * camera.k + camera.y <= height; + if (!visible(d.source) || !visible(d.target)) return 'none'; + } + return null; + }; const applyEdgeDisplays = () => { linkElements.style('display', edgeDisplay); clickableEdges.style('display', edgeDisplay); @@ -3890,6 +3939,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i updateZoomPresentation(event.transform.k); }); + zoom.on('start.local-item', event => { + window.dispatchEvent(new CustomEvent('graphdone:camera-start', { detail: { manual: !!event.sourceEvent } })); + }); + zoom.on('end.local-item', event => { + window.dispatchEvent(new CustomEvent('graphdone:camera-settled', { detail: { x: event.transform.x, y: event.transform.y, scale: event.transform.k, manual: !!event.sourceEvent } })); + }); zoom.on('end.labels', () => { if (simulationRef.current === simulation) itemPresentation.schedule(); }); @@ -3987,6 +4042,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (beginSession || overviewReturnCamera.current?.graphId !== currentGraphIdRef.current) { overviewReturnCamera.current = { graphId: currentGraphIdRef.current, transform: d3.zoomTransform(svgRef.current) }; } + requestLocalFocus(id); // Selection is intentional here; never invoke the Grow/connect click path. setSelectedNode(item); setSelectedNodes(new Set([id])); @@ -3996,7 +4052,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const k = 1.2; const transform = d3.zoomIdentity.translate(containerRef.current.clientWidth / 2 - item.x * k, containerRef.current.clientHeight / 2 - item.y * k).scale(k); d3.select(svgRef.current).interrupt().call(zoomBehaviorRef.current.transform, transform); - }, []); + }, [requestLocalFocus]); const returnToOverview = useCallback(() => { setSelectedNode(null); const saved = overviewReturnCamera.current; @@ -4019,15 +4075,19 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } }, [semanticOverviewActive, currentGraph?.id]); - // True only while "Reset layout" is re-flowing — suspends snapshot pinning - const layoutReflowingRef = useRef(false); const mousedownNodeRef = useRef(null); + const organizationZoomFloorRef = useRef(0); + const organizationFocusRef = useRef(); + useEffect(() => { + organizationZoomFloorRef.current = 0; + organizationFocusRef.current = undefined; + }, [layoutScope]); // Fit view to show all nodes. Returns true if a fit transform was applied, // false if it couldn't yet (no svg/container, or the simulation has no // positioned nodes) — the once-per-graph framing polls on this so a slow data // load doesn't leave the graph pinned off-screen (the live-guest bug). - const fitViewToNodes = useCallback((animate = true): boolean => { + const fitViewToNodes = useCallback((animate = true, minimumScale = organizationZoomFloorRef.current): boolean => { if (!svgRef.current || !containerRef.current) return false; // Bounds must come from the LIVE simulation objects (the React-state // nodes are different objects since the identity merge and may hold @@ -4067,6 +4127,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const containerRect = container.getBoundingClientRect(); const gap = 12; const obstacles = Array.from(document.querySelectorAll('[data-graph-fit-obstacle="true"]')) + .filter(element => !(minimumScale > 0 && element.dataset.layoutSaveProgress === 'true')) .map(element => { const rect = element.getBoundingClientRect(); return { @@ -4113,10 +4174,23 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } // Fit, but never zoom IN past 1.25 (a 3-node graph shouldn't fill the screen) - const scale = Math.min(fitRegion.width / boundsWidth, fitRegion.height / boundsHeight, 1.25); - - const translateX = fitRegion.x + fitRegion.width / 2 - ((minX + maxX) / 2) * scale; - const translateY = fitRegion.y + fitRegion.height / 2 - ((minY + maxY) / 2) * scale; + const scale = Math.max(minimumScale, Math.min(fitRegion.width / boundsWidth, fitRegion.height / boundsHeight, 1.25)); + + let translateX = fitRegion.x + fitRegion.width / 2 - ((minX + maxX) / 2) * scale; + let translateY = fitRegion.y + fitRegion.height / 2 - ((minY + maxY) / 2) * scale; + const focus = organizationFocusRef.current && simNodes.find(node => node.id === organizationFocusRef.current); + if (minimumScale > 0 && focus) { + const at = frameOrganizedNeighborhood( + simNodes.map(node => ({ id: node.id, x: node.x, y: node.y, ...graphItemLayout(node, measureGraphText, scale) })), + focus.id, + { width, height }, + obstacles.map(rect => ({ x: rect.left, y: rect.top, width: rect.right - rect.left, height: rect.bottom - rect.top })), + scale, + { x: width / 2, y: height / 2 } + ); + translateX = at.x - focus.x * scale; + translateY = at.y - focus.y * scale; + } const transform = d3.zoomIdentity.translate(translateX, translateY).scale(scale); // Use the component's own zoom behavior so handlers fire and state stays @@ -4261,9 +4335,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }); }; window.addEventListener('graphdone:minimap-open', snapshot); + window.addEventListener('graphdone:layout-organized', publish); const id = setInterval(() => { publish(); if (++n >= 5) clearInterval(id); }, 800); publish(); - return () => { clearInterval(id); window.removeEventListener('graphdone:minimap-open', snapshot); }; + return () => { + clearInterval(id); + window.removeEventListener('graphdone:minimap-open', snapshot); + window.removeEventListener('graphdone:layout-organized', publish); + }; }, [currentGraph?.id, workItemsData?.workItems?.length]); // Center on specific node @@ -4351,36 +4430,40 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }, []); - // Reset layout = the explicit "re-flow everything" escape hatch. It must - // override snapshot-authoritative pinning: clear each node's saved-position - // intent (in memory) and unpin, suspend re-pinning while physics rearranges, - // then persist the fresh layout once it settles so it becomes the new - // authoritative snapshot. const resetLayout = useCallback(() => { - layoutReflowingRef.current = true; - // Unpin + mark unplaced; initializeVisualization will lay the unplaced - // nodes out on a CLEAN spread grid (see getUnplacedSpread) so physics - // REFINES a non-overlapping start instead of trying to explode a pile. - nodes.forEach((node: any) => { - node.userPinned = false; + const simulation = simulationRef.current, container = containerRef.current; + if (!simulation || !container || simulationLayoutScopeRef.current !== activeLayoutScopeRef.current) return; + layoutModeRef.current = 'force'; + try { window.localStorage.setItem(LAYOUT_MODE_STORAGE_KEY, 'force'); } catch (error) { console.warn('Unable to save layout mode', error); } + window.dispatchEvent(new CustomEvent('graphdone-layout-mode-change', { detail: 'force' })); + const arranged = organizeTopology(simulation.nodes() as WorkItem[], validatedEdges as any[], node => graphItemLayout(node, measureGraphText), {width:container.clientWidth,height:container.clientHeight}); + for (const node of simulation.nodes() as any[]) { + const point = arranged.positions.get(node.id); + if (!point) continue; + node.x = node.fx = point.x; + node.y = node.fy = point.y; + node.vx = node.vy = 0; + node.userPinned = true; + delete node.__layerPinned; + delete node.__preLayerFx; + delete node.__preLayerFy; node.userPreferredPosition = null; node.userPreferenceVector = null; - node.fx = null; - node.fy = null; - node.positionX = 0; - node.positionY = 0; - node.targetX = null; - node.targetY = null; - node.x = 0; - node.y = 0; - }); - initializeVisualization(); - setTimeout(() => { + } + (window as any).__graphOrganization = {components:arranged.components,work:arranged.work}; + organizationZoomFloorRef.current = 0.65; + organizationFocusRef.current = arranged.components[0]?.center ?? arranged.components[0]?.ids[0]; + cameraManuallyPositionedRef.current = true; + if (currentGraphIdRef.current) fittedGraphsRef.current.add(currentGraphIdRef.current); + simulation.alpha(0).restart(); + window.dispatchEvent(new CustomEvent('graphdone:layout-organized')); + persistAllPositions(); + requestAnimationFrame(() => { + if (simulationRef.current !== simulation) return; + runLabelAvoidanceRef.current?.(); fitViewToNodes(); - persistAllPositions(); // the reflowed layout becomes the new snapshot - layoutReflowingRef.current = false; - }, 2500); - }, [nodes, initializeVisualization, fitViewToNodes, persistAllPositions]); + }); + }, [validatedEdges, fitViewToNodes, persistAllPositions]); // Hierarchical mode packs actual card sizes into compact dependency rows. // Pin both axes so repulsion cannot spread a finished row across the viewport. @@ -4391,6 +4474,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const applyLayoutMode = useCallback((mode: LayoutMode) => { layoutModeRef.current = mode; try { window.localStorage?.setItem(LAYOUT_MODE_STORAGE_KEY, mode); } catch { /* storage unavailable */ } + window.dispatchEvent(new CustomEvent('graphdone-layout-mode-change', { detail: mode })); const sim = simulationRef.current; const container = containerRef.current; if (!sim || !container) return; @@ -4484,6 +4568,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i && cameraViewportRef.current.h === containerRef.current?.clientHeight && cameraResizeFrameRef.current === null && !(svgRef.current as (SVGSVGElement & { __transition?: unknown }) | null)?.__transition, + cameraFramed: !!currentGraphIdRef.current && fittedGraphsRef.current.has(currentGraphIdRef.current), cameraViewport: cameraViewportRef.current, alpha: Math.round(alpha * 10000) / 10000, lastSettleMs: lastSettleMsRef.current, @@ -4622,6 +4707,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const frame = containerRef.current?.getBoundingClientRect(); const svg = svgRef.current; if (!frame || !svg) return false; + if (localItemActiveRef.current) { + const focusId = containerRef.current?.querySelector('[data-local-focus]')?.dataset.localFocus; + const focus = simulationRef.current?.nodes().find(node => node.id === focusId); + const transform = d3.zoomTransform(svg); + return !!focus && focus.x * transform.k + transform.x >= 0 && focus.x * transform.k + transform.x <= frame.width && focus.y * transform.k + transform.y >= 0 && focus.y * transform.k + transform.y <= frame.height; + } const cards = Array.from(svg.querySelectorAll('.node-bg')) .map(node => node.getBoundingClientRect()); const showsANode = cards.some(card => card.width > 0 && card.height > 0 @@ -4792,7 +4883,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // every node without touching the camera-restore state. Calls through the ref so // the latest fit fn is used regardless of when the button mounts. useEffect(() => { - (window as any).triggerZoomToFit = () => fitViewRef.current(); + (window as any).triggerZoomToFit = () => { + organizationZoomFloorRef.current = 0; + return fitViewRef.current(); + }; return () => { delete (window as any).triggerZoomToFit; }; @@ -4815,7 +4909,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i useEffect(() => { if (!hasNodes) return undefined; if (!isHierarchicalLayout(layoutModeRef.current)) return undefined; - const timer = setTimeout(() => applyLayoutModeRef.current('hierarchical'), 1600); + const timer = setTimeout(() => { + if (isHierarchicalLayout(layoutModeRef.current)) applyLayoutModeRef.current('hierarchical'); + }, 1600); return () => clearTimeout(timer); }, [hasNodes, currentGraphId]); @@ -5201,13 +5297,13 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return ( -
- {workItems.length < 40 &&
{priorityControl}
} + {workItems.length < 40 &&
{priorityControl}{localItemControls}
} {!semanticOverviewActive && - {workItemEdges.length > 0 && + + + {request?.sequence ?? 'consumed'} + + + setRequest(current => + current?.sequence === handled ? undefined : current + ) + } + /> + + ); + } + const view = render(); + fireEvent.click(screen.getByRole('button', { name: 'Navigate to B' })); + expect(focus(view.container)).toBe('B'); + expect(screen.getByTestId('request-state')).toHaveTextContent('consumed'); + fireEvent.click(screen.getByRole('button', { name: 'Focus on b0' })); + expect(focus(view.container)).toBe('b0'); + fireEvent.click(screen.getByRole('button', { name: 'Navigate to B' })); + expect(focus(view.container)).toBe('B'); + fireEvent.click(screen.getByRole('button', { name: 'Focus on b0' })); + fireEvent.click(screen.getByRole('button', { name: 'Switch graph' })); + expect(focus(view.container)).toBe('A'); + fireEvent.click(screen.getByRole('button', { name: 'Switch graph' })); + expect(focus(view.container)).toBe('b0'); +}); + +it('reserves full title height for the actual narrow-screen page rather than a different desktop page', () => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + return this.classList.contains('local-item-neighborhood') + ? rectangle(390, 685) + : rectangle(0, 0); + } + ); + const original = makeIndex(); + const index = indexSemanticRelationships( + [...original.byId.values()].map(item => + item.id === 'a1' + ? { + ...item, + title: `a1 ${'workflow automation reconciliation '.repeat(10)}`, + } + : item + ), + original.edges + ); + const scope = 'narrow-title-page'; + localStorage.setItem( + `graphdone:local-items:${scope}`, + JSON.stringify({ + session: { focusId: 'A', page: 1 }, + history: [], + board: false, + }) + ); + const view = render( + + ); + const cards = [ + ...view.container.querySelectorAll('[data-local-item-card]'), + ].map(card => ({ + x: parseFloat(card.style.left), + y: parseFloat(card.style.top), + width: parseFloat(card.style.width), + height: parseFloat(card.style.height), + })); + expect(cards.some(card => card.height === 152)).toBe(true); + for (const [i, a] of cards.entries()) + for (const b of cards.slice(i + 1)) + expect( + Math.hypot( + Math.max(0, a.x - b.x - b.width, b.x - a.x - a.width), + Math.max(0, a.y - b.y - b.height, b.y - a.y - a.height) + ) + ).toBeGreaterThanOrEqual(64); +}); diff --git a/packages/web/src/components/LocalItemNeighborhood.tsx b/packages/web/src/components/LocalItemNeighborhood.tsx new file mode 100644 index 00000000..c8c115d7 --- /dev/null +++ b/packages/web/src/components/LocalItemNeighborhood.tsx @@ -0,0 +1,698 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { projectItemNeighborhood } from '../lib/localItemLOD'; +import { + denseItemCandidates, + localCardDimensions, + localItemCellDimensions, + localItemSlots, + nearestDenseItem, + type LocalCamera, + type LocalItemObstacle, +} from '../lib/localItemLayout'; +import { + routeSchematicConnections, + type SchematicCard, + type SchematicRoute, +} from '../lib/schematicRouting'; +import type { + RelationshipScope, + SemanticRelationshipIndex, +} from '../lib/semanticRelationships'; +import { + getRelationshipConfig, + type RelationshipType, +} from '../constants/workItemConstants'; +import { OverviewBusLayer } from './OverviewBusLayer'; +import { + LocalItemCardView, + LocalItemControls, + LocalItemFooter, +} from './LocalItemNeighborhoodView'; + +type Session = { + focusId: string; + page: number; + groupId?: string; + camera?: LocalCamera; +}; +function readLocalView(scope: string | null): { + session?: Session; + history: Session[]; + board: boolean; +} { + try { + const raw = scope + ? JSON.parse( + localStorage.getItem(`graphdone:local-items:${scope}`) ?? 'null' + ) + : null; + const valid = (entry: unknown): entry is Session => { + if (!entry || typeof entry !== 'object') return false; + const value = entry as Session; + return ( + typeof value.focusId === 'string' && + value.focusId.length <= 256 && + Number.isSafeInteger(value.page) && + value.page >= 0 && + value.page <= 1000000 && + (value.groupId === undefined || + (typeof value.groupId === 'string' && + value.groupId.length <= 16384)) && + (value.camera === undefined || + ([value.camera.x, value.camera.y, value.camera.scale].every( + Number.isFinite + ) && + value.camera.scale > 0)) + ); + }; + return { + session: valid(raw?.session) ? raw.session : undefined, + history: Array.isArray(raw?.history) + ? raw.history.filter(valid).slice(-32) + : [], + board: raw?.board === true, + }; + } catch { + return { history: [], board: false }; + } +} + +type Props = { + graphId: string; + viewScope: string | null; + index: SemanticRelationshipIndex; + representedIds: ReadonlySet; + camera: LocalCamera; + selectedId?: string; + navigationRequest?: { id: string; sequence: number }; + onNavigationHandled?: (sequence: number) => void; + enabled: boolean; + readPositions: () => readonly { id: string; x: number; y: number }[]; + onActive: (active: boolean) => void; + onControls: (controls: ReactNode) => void; + onFooter: (footer: ReactNode) => void; + onInspect: (id: string) => void; + onNavigate: (id: string, camera?: LocalCamera) => void; + onBoard: (active: boolean) => void; + onBrowse: ( + scope: RelationshipScope, + trigger: HTMLElement | SVGElement + ) => void; +}; + +export function LocalItemNeighborhood({ + viewScope, + index, + representedIds, + camera, + selectedId, + navigationRequest, + onNavigationHandled, + enabled, + readPositions, + onActive, + onControls, + onFooter, + onInspect, + onNavigate, + onBoard, + onBrowse, +}: Props) { + const host = useRef(null); + const [geometry, setGeometry] = useState({ + width: 0, + height: 0, + obstacles: [] as LocalItemObstacle[], + }); + const [restored] = useState(() => readLocalView(viewScope)); + const [session, setSession] = useState(restored.session); + const [history, setHistory] = useState(restored.history); + const [board, setBoard] = useState(restored.board); + useEffect(() => { + if (!viewScope) return; + try { + localStorage.setItem( + `graphdone:local-items:${viewScope}`, + JSON.stringify({ session, history: history.slice(-32), board }) + ); + } catch { + return; + } + }, [viewScope, session, history, board]); + const [highlight, setHighlight] = useState(null); + const origin = useRef(); + const worldAnchor = useRef<{ x: number; y: number }>(); + const cameraRef = useRef(camera); + cameraRef.current = camera; + const rebasePending = useRef(false); + const readPoint = useCallback( + (id?: string) => { + const point = readPositions().find(item => item.id === id); + return point ? { x: point.x, y: point.y } : undefined; + }, + [readPositions] + ); + const candidates = useMemo(() => denseItemCandidates(index), [index]); + const candidate = + enabled && !board && !session + ? nearestDenseItem( + candidates, + new Map(readPositions().map(item => [item.id, item])), + camera, + geometry, + selectedId + ) + : undefined; + const focusId = session?.focusId ?? candidate; + const active = + enabled && + !board && + !!focusId && + index.byId.has(focusId) && + geometry.width > 0 && + geometry.height > 0; + useLayoutEffect(() => { + const element = host.current; + if (!element) return; + const measure = () => { + const bounds = element.getBoundingClientRect(); + const obstacles = Array.from( + document.querySelectorAll( + '[data-graph-fit-obstacle="true"], [data-graph-status-dock="true"]' + ) + ) + .filter( + item => + !item.hasAttribute('data-local-item-card') && + (item.getAttribute('role') !== 'region' || + item.hasAttribute('data-lod-trace')) && + item.dataset.layoutSaveProgress !== 'true' + ) + .map(item => item.getBoundingClientRect()) + .filter(rect => rect.width > 0 && rect.height > 0) + .map(rect => ({ + x: rect.left - bounds.left, + y: rect.top - bounds.top, + width: rect.width, + height: rect.height, + })); + const next = { width: bounds.width, height: bounds.height, obstacles }; + setGeometry(previous => + JSON.stringify(previous) === JSON.stringify(next) ? previous : next + ); + }; + const observer = new ResizeObserver(measure); + observer.observe(element); + document + .querySelectorAll( + '[data-graph-fit-obstacle="true"], [data-graph-status-dock="true"]' + ) + .forEach(item => observer.observe(item)); + measure(); + return () => observer.disconnect(); + }, [active, session?.page, session?.groupId]); + useEffect(() => { + if (index.byId.size > 0 && session && !index.byId.has(session.focusId)) { + setSession(undefined); + setHistory([]); + } + }, [session, index]); + useLayoutEffect(() => { + if (!active || !focusId) return; + if (!session) setSession({ focusId, page: 0 }); + if (!origin.current) { + origin.current = camera; + worldAnchor.current = readPoint(focusId); + } + }, [active, focusId, session, camera, readPoint]); + useLayoutEffect(() => { + onActive(active); + return () => onActive(false); + }, [active, onActive]); + const previousEnabled = useRef(enabled); + const selectionAtExit = useRef(selectedId); + useLayoutEffect(() => { + const returning = enabled && !previousEnabled.current; + if (!enabled && previousEnabled.current) + selectionAtExit.current = selectedId; + previousEnabled.current = enabled; + if (!returning) return; + setBoard(false); + if ( + selectedId && + selectedId !== selectionAtExit.current && + index.byId.has(selectedId) && + (session || candidates.includes(selectedId)) + ) { + origin.current = camera; + worldAnchor.current = readPoint(selectedId); + setSession({ focusId: selectedId, page: 0 }); + } + }, [enabled, selectedId, index, candidates, session, camera, readPoint]); + const handledNavigation = useRef(); + useLayoutEffect(() => { + if ( + !navigationRequest || + navigationRequest.sequence === handledNavigation.current || + !index.byId.has(navigationRequest.id) + ) + return; + handledNavigation.current = navigationRequest.sequence; + onNavigationHandled?.(navigationRequest.sequence); + if (!session && !candidates.includes(navigationRequest.id)) return; + origin.current = camera; + worldAnchor.current = readPoint(navigationRequest.id); + setBoard(false); + setSession({ focusId: navigationRequest.id, page: 0 }); + }, [ + navigationRequest, + onNavigationHandled, + index, + candidates, + session, + camera, + readPoint, + ]); + useLayoutEffect(() => { + onBoard(board); + return () => onBoard(false); + }, [board, onBoard]); + useEffect(() => { + const organized = () => { + rebasePending.current = true; + }; + const started = (event: Event) => { + if (!(event as CustomEvent<{ manual: boolean }>).detail.manual) + rebasePending.current = true; + }; + const settled = (event: Event) => { + const next = (event as CustomEvent) + .detail; + const point = readPoint(focusId); + if ( + point && + (point.x * next.scale + next.x < 0 || + point.x * next.scale + next.x > geometry.width || + point.y * next.scale + next.y < 0 || + point.y * next.scale + next.y > geometry.height) + ) { + origin.current = undefined; + worldAnchor.current = undefined; + setSession(undefined); + rebasePending.current = false; + return; + } + if (!rebasePending.current && next.manual) return; + rebasePending.current = false; + origin.current = next; + worldAnchor.current = point; + setGeometry(previous => ({ ...previous })); + }; + window.addEventListener('graphdone:layout-organized', organized); + window.addEventListener('graphdone:camera-start', started); + window.addEventListener('graphdone:camera-settled', settled); + return () => { + window.removeEventListener('graphdone:layout-organized', organized); + window.removeEventListener('graphdone:camera-start', started); + window.removeEventListener('graphdone:camera-settled', settled); + }; + }, [focusId, readPoint, geometry.width, geometry.height]); + useLayoutEffect(() => { + let second = 0; + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => { + origin.current = cameraRef.current; + worldAnchor.current = readPoint(focusId); + rebasePending.current = false; + setGeometry(previous => ({ ...previous })); + }); + }); + return () => { + cancelAnimationFrame(first); + cancelAnimationFrame(second); + }; + }, [geometry.width, geometry.height, readPoint]); + const cell = useMemo( + () => localItemCellDimensions(geometry.width), + [geometry.width] + ); + const layout = useMemo( + () => localItemSlots(geometry, geometry.obstacles, cell), + [geometry, cell] + ); + const model = useMemo( + () => + projectItemNeighborhood(index, focusId ?? '', { + capacity: layout.slots.length, + page: session?.page ?? 0, + groupId: session?.groupId, + representedIds, + }), + [ + index, + focusId, + layout.slots.length, + session?.page, + session?.groupId, + representedIds, + ] + ); + const cards = useMemo( + () => + model.cards.map((card, i) => { + const slot = layout.slots[i], + size = localCardDimensions(card, geometry.width); + return { + ...card, + x: slot.x + (slot.width - size.width) / 2, + y: slot.y + (slot.height - size.height) / 2, + width: size.width, + height: size.height, + title: size.title, + }; + }), + [model.cards, layout.slots, geometry.width] + ); + const wiring = useMemo( + () => + active + ? routeSchematicConnections( + cards, + model.connections, + geometry.width, + geometry.height, + geometry.obstacles.map((box, i) => ({ + ...box, + id: `local-control-${i}`, + })), + { cardClearance: 20, wireClearance: 16 } + ) + : undefined, + [active, cards, model.connections, geometry] + ); + const drawn = + wiring?.routes.reduce((sum, route) => sum + route.edgeIds.length, 0) ?? 0; + const routingDeferred = + wiring?.deferred.reduce((sum, route) => sum + route.edgeIds.length, 0) ?? 0; + const navigate = useCallback( + (next: Session, keep = true) => { + if (keep && session) + setHistory(previous => [ + ...previous, + { ...session, camera: cameraRef.current }, + ]); + if (next.focusId !== session?.focusId || next.camera) { + rebasePending.current = true; + onNavigate(next.focusId, next.camera); + } + setSession({ + focusId: next.focusId, + page: next.page, + groupId: next.groupId, + }); + }, + [session, onNavigate] + ); + const browseFocus = useCallback( + (trigger: HTMLElement | SVGElement) => { + if (focusId) onBrowse({ kind: 'node', id: focusId }, trigger); + }, + [focusId, onBrowse] + ); + const controls = useMemo( + () => + board && enabled && focusId ? ( + { + origin.current = cameraRef.current; + worldAnchor.current = readPoint(focusId); + setBoard(false); + }} + /> + ) : active && model.focus ? ( + 0 + ? 'Back to previous item' + : 'Previous linked items' + } + previousDisabled={model.page === 0 && history.length === 0} + nextDisabled={ + model.page + 1 >= model.pages || model.counts.shownNeighbors === 0 + } + onBrowse={browseFocus} + onPrevious={() => { + if (model.page === 0 && history.length > 0) { + const previous = history[history.length - 1]; + setHistory(value => value.slice(0, -1)); + navigate(previous, false); + } else + navigate( + { + focusId: focusId!, + page: model.page - 1, + groupId: session?.groupId, + }, + false + ); + }} + onNext={() => + navigate( + { + focusId: focusId!, + page: model.page + 1, + groupId: session?.groupId, + }, + false + ) + } + /> + ) : null, + [ + active, + board, + enabled, + readPoint, + model.focus, + model.groups, + model.counts.shownNeighbors, + model.counts.loadedNeighbors, + model.page, + model.pages, + focusId, + session?.groupId, + history, + navigate, + browseFocus, + ] + ); + const footer = useMemo( + () => + active && model.focus ? ( + onBrowse({ kind: 'all' }, trigger)} + onGroupChange={groupId => + navigate({ focusId: focusId!, page: 0, groupId }, false) + } + onEditBoard={() => { + setBoard(true); + if (focusId) onNavigate(focusId); + }} + onResetGroup={() => navigate({ focusId: focusId!, page: 0 }, false)} + /> + ) : null, + [ + active, + model.focus, + model.counts, + model.groups, + model.missingGroup, + drawn, + routingDeferred, + browseFocus, + onBrowse, + session?.groupId, + navigate, + focusId, + onNavigate, + ] + ); + useLayoutEffect(() => { + onControls(controls); + }, [controls, onControls]); + useLayoutEffect(() => { + onFooter(footer); + }, [footer, onFooter]); + useLayoutEffect( + () => () => { + onControls(null); + onFooter(null); + }, + [onControls, onFooter] + ); + const cameraOrigin = origin.current ?? camera; + const anchor = worldAnchor.current; + const delta = + anchor && !rebasePending.current + ? { + x: + camera.x - + cameraOrigin.x + + anchor.x * (camera.scale - cameraOrigin.scale), + y: + camera.y - + cameraOrigin.y + + anchor.y * (camera.scale - cameraOrigin.scale), + } + : { x: 0, y: 0 }; + const route = wiring?.routes.find(candidate => candidate.id === highlight); + const cardLabel = useCallback( + (id: string) => { + const card = model.cards.find(item => item.id === id); + return card?.kind === 'item' ? card.item.title : (card?.label ?? id); + }, + [model.cards] + ); + const inspectBus = useCallback( + (bus: SchematicRoute, trigger: SVGPathElement) => + onBrowse( + { + kind: 'selection', + label: `${cardLabel(bus.source)} → ${cardLabel(bus.target)}`, + edgeIds: bus.edgeIds, + }, + trigger + ), + [onBrowse, cardLabel] + ); + useEffect(() => { + if (!active) return; + (window as unknown as { __localItemLOD?: unknown }).__localItemLOD = { + focusId, + cards: cards.map(card => ({ + id: card.id, + kind: card.kind, + ...(card.kind === 'item' + ? { originalId: card.item.id } + : { members: card.members.map(item => item.id) }), + })), + counts: model.counts, + partitionIds: Object.fromEntries( + Object.entries(model.partitions).map(([key, edges]) => [ + key, + (edges as readonly import('../lib/semanticLOD').SemanticEdge[]).map( + edge => edge.id + ), + ]) + ), + routes: wiring?.routes.map(bus => ({ + source: bus.source, + target: bus.target, + edgeIds: bus.edgeIds, + })), + routingDeferred, + work: { ...model.work, ...layout.work, routing: wiring?.work }, + }; + return () => { + delete (window as unknown as { __localItemLOD?: unknown }).__localItemLOD; + }; + }, [active, focusId, cards, model, wiring, routingDeferred, layout.work]); + return ( +
event.stopPropagation()} + data-local-focus={active ? focusId : undefined} + > + {active && ( +
+ ({ + ...box, + id: `local-control-${i}`, + })), + ] as SchematicCard[] + } + width={geometry.width} + height={geometry.height} + color={busId => { + const bus = wiring?.routes.find(item => item.bus === busId); + return getRelationshipConfig( + (bus?.connections[0]?.type ?? 'DEPENDS_ON') as RelationshipType + ).hexColor; + }} + label={cardLabel} + onHighlight={setHighlight} + onInspect={inspectBus} + onFocusLost={id => + host.current + ?.querySelector( + `[data-local-open="${CSS.escape(id)}"]` + ) + ?.focus() + } + /> + {cards.map(card => ( + + card.kind === 'item' + ? onInspect(card.item.id) + : navigate({ + focusId: focusId!, + page: 0, + groupId: card.groupId, + }) + } + onExplore={trigger => + card.kind === 'item' + ? card.isFocus + ? browseFocus(trigger) + : navigate({ focusId: card.item.id, page: 0 }) + : navigate({ + focusId: focusId!, + page: 0, + groupId: card.groupId, + }) + } + /> + ))} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/LocalItemNeighborhoodView.tsx b/packages/web/src/components/LocalItemNeighborhoodView.tsx new file mode 100644 index 00000000..5d1a76db --- /dev/null +++ b/packages/web/src/components/LocalItemNeighborhoodView.tsx @@ -0,0 +1,298 @@ +import { + ArrowLeft, + ArrowRight, + Compass, + FolderTree, + List, + Move, +} from 'lucide-react'; +import { GRAPH_FONT_FAMILY } from '../lib/graphSymbolLayout'; +import type { LocalItemCard, LocalItemGroup } from '../lib/localItemLOD'; +import { + getStatusConfig, + getTypeConfig, + type WorkItemStatus, + type WorkItemType, +} from '../constants/workItemConstants'; + +type LocalItemControlsProps = + | { kind: 'board'; onExplore: () => void } + | { + kind: 'items'; + focusTitle: string; + shownNeighbors: number; + loadedNeighbors: number; + previousLabel: string; + previousDisabled: boolean; + nextDisabled: boolean; + onBrowse: (trigger: HTMLButtonElement) => void; + onPrevious: () => void; + onNext: () => void; + }; + +export function LocalItemControls(props: LocalItemControlsProps) { + if (props.kind === 'board') + return ( + + ); + return ( +
+ + + +
+ ); +} + +type LocalItemFooterProps = { + counts: { + incident: number; + spatialDeferred: number; + pending: number; + internal: number; + prioritySummarized: number; + }; + groups: readonly LocalItemGroup[]; + selectedGroup?: string; + missingGroup: boolean; + drawn: number; + routingDeferred: number; + onBrowseFocus: (trigger: HTMLButtonElement) => void; + onBrowseAll: (trigger: HTMLButtonElement) => void; + onGroupChange: (id?: string) => void; + onEditBoard: () => void; + onResetGroup: () => void; +}; + +export function LocalItemFooter({ + counts, + groups, + selectedGroup, + missingGroup, + drawn, + routingDeferred, + onBrowseFocus, + onBrowseAll, + onGroupChange, + onEditBoard, + onResetGroup, +}: LocalItemFooterProps) { + return ( +
+
+ + + {(groups.length > 1 || selectedGroup) && ( + + )} + +
+ {missingGroup && ( +
+

The selected group changed.

+ +
+ )} +

+ {drawn} links shown · {counts.spatialDeferred + routingDeferred} in list +

+ {(counts.pending > 0 || + counts.internal > 0 || + counts.prioritySummarized > 0) && ( +

+ {counts.prioritySummarized} by priority · {counts.internal} internal ·{' '} + {counts.pending} awaiting endpoints +

+ )} +
+ ); +} + +type LocalItemCardViewProps = { + card: LocalItemCard & { + x: number; + y: number; + width: number; + height: number; + title: { lines: readonly string[] }; + }; + emphasized: boolean; + onOpen: () => void; + onExplore: (trigger: HTMLButtonElement) => void; +}; + +export function LocalItemCardView({ + card, + emphasized, + onOpen, + onExplore, +}: LocalItemCardViewProps) { + const config = + card.kind === 'item' + ? getTypeConfig(card.item.type as WorkItemType) + : undefined; + const Icon = config?.icon ?? FolderTree; + const status = + card.kind === 'item' + ? getStatusConfig(card.item.status as WorkItemStatus) + : undefined; + return ( +
+
+ + {config?.label ?? 'Linked group'} + {card.kind === 'item' && card.isFocus && ( + Focus + )} +
+ +
+ + {card.kind === 'item' + ? (status?.label ?? card.item.status) + : `${card.members.length} connected items`} + + +
+
+ ); +} diff --git a/packages/web/src/components/MobileBottomNav.tsx b/packages/web/src/components/MobileBottomNav.tsx index 6bde84a6..4b968b2a 100644 --- a/packages/web/src/components/MobileBottomNav.tsx +++ b/packages/web/src/components/MobileBottomNav.tsx @@ -73,21 +73,22 @@ export function MobileBottomNav() { <>