@sleep2agi/agent-network-dashboard 0.4.6-preview.20 → 0.4.6-preview.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +3 -3
- package/.next/diagnostics/route-bundle-stats.json +41 -41
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/routes-manifest.json +6 -0
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +12 -12
- package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
- package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
- package/.next/server/app/admin.html +2 -2
- package/.next/server/app/admin.rsc +14 -14
- package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
- package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
- package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
- package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
- package/.next/server/app/api/hub/servers/route/app-paths-manifest.json +3 -0
- package/.next/server/app/api/hub/servers/route/build-manifest.json +9 -0
- package/.next/server/app/api/hub/servers/route/server-reference-manifest.json +4 -0
- package/.next/server/app/api/hub/servers/route.js +7 -0
- package/.next/server/app/api/hub/servers/route.js.map +5 -0
- package/.next/server/app/api/hub/servers/route.js.nft.json +1 -0
- package/.next/server/app/api/hub/servers/route_client-reference-manifest.js +3 -0
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +14 -14
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/server/app/index.segments/_index.segment.rsc +7 -7
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +2 -2
- package/.next/server/app/login.rsc +14 -14
- package/.next/server/app/login.segments/_full.segment.rsc +14 -14
- package/.next/server/app/login.segments/_head.segment.rsc +4 -4
- package/.next/server/app/login.segments/_index.segment.rsc +7 -7
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/login.segments/login.segment.rsc +3 -3
- package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/logs.html +2 -2
- package/.next/server/app/logs.rsc +14 -14
- package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
- package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
- package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
- package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
- package/.next/server/app/messages.html +2 -2
- package/.next/server/app/messages.rsc +14 -14
- package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
- package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
- package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
- package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
- package/.next/server/app/node/page_client-reference-manifest.js +1 -1
- package/.next/server/app/node.html +2 -2
- package/.next/server/app/node.rsc +14 -14
- package/.next/server/app/node.segments/_full.segment.rsc +14 -14
- package/.next/server/app/node.segments/_head.segment.rsc +4 -4
- package/.next/server/app/node.segments/_index.segment.rsc +7 -7
- package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/node.segments/node.segment.rsc +3 -3
- package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
- package/.next/server/app/nodes.html +2 -2
- package/.next/server/app/nodes.rsc +14 -14
- package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
- package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
- package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/server-logs.html +2 -2
- package/.next/server/app/server-logs.rsc +14 -14
- package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
- package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
- package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/networks.html +2 -2
- package/.next/server/app/settings/networks.rsc +14 -14
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
- package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens.html +2 -2
- package/.next/server/app/settings/tokens.rsc +14 -14
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
- package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings.html +2 -2
- package/.next/server/app/settings.rsc +14 -14
- package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
- package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
- package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
- package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks.html +2 -2
- package/.next/server/app/tasks.rsc +14 -14
- package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
- package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
- package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
- package/.next/server/app-paths-manifest.json +1 -0
- package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js +3 -0
- package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js.map +1 -0
- package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js +3 -0
- package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js.map +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js.map +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +4 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js.map +1 -1
- package/.next/server/middleware-build-manifest.js +3 -3
- package/.next/server/pages/404.html +2 -2
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/0-mpa_947ipeq.js +1 -0
- package/.next/static/chunks/01b6zl3rabau1.js +1 -0
- package/.next/static/chunks/01lmbsd37fybu.js +1 -0
- package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
- package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
- package/.next/static/chunks/05uk96gc~9mni.js +1 -0
- package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
- package/.next/static/chunks/0fzk-axksemic.css +2 -0
- package/.next/static/chunks/0ggj5ummw2a84.js +7 -0
- package/.next/static/chunks/0glmrdj9112tc.js +7 -0
- package/.next/static/chunks/0n7ow3nu-_bsk.js +4 -0
- package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
- package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
- package/.next/static/chunks/0zx.24yobat5o.js +1 -0
- package/.next/static/chunks/174re5ar~kxne.js +1 -0
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/.next/types/routes.d.ts +2 -1
- package/.next/types/validator.ts +9 -0
- package/app/api/hub/servers/route.ts +39 -0
- package/app/components/ChatPopover.tsx +38 -2
- package/app/components/CommandPalette.tsx +107 -0
- package/app/components/HelpOverlay.tsx +126 -0
- package/app/components/ServersDrawer.tsx +64 -27
- package/app/components/TopoGraph.tsx +3984 -264
- package/app/components/utils.ts +8 -7
- package/app/globals.css +110 -5
- package/app/lib/time.ts +36 -0
- package/package.json +1 -1
- package/scripts/help-overlay-topo-test.mjs +44 -0
- package/scripts/servers-drawer-live-test.mjs +96 -0
- package/scripts/topo-active-filter-pill-test.mjs +101 -0
- package/scripts/topo-active-links-chip-hover-test.mjs +103 -0
- package/scripts/topo-active-links-chip-nav-test.mjs +117 -0
- package/scripts/topo-active-links-chip-self-hover-test.mjs +171 -0
- package/scripts/topo-active-links-chip-test.mjs +73 -0
- package/scripts/topo-active-links-freshness-test.mjs +113 -0
- package/scripts/topo-active-links-tooltip-test.mjs +79 -0
- package/scripts/topo-arrival-ping-test.mjs +87 -0
- package/scripts/topo-arrow-tier-test.mjs +61 -0
- package/scripts/topo-auto-fit-test.mjs +73 -0
- package/scripts/topo-canvas-aria-test.mjs +108 -0
- package/scripts/topo-chat-target-breath-test.mjs +84 -0
- package/scripts/topo-chat-target-ring-fade-test.mjs +124 -0
- package/scripts/topo-chip-focus-test.mjs +95 -0
- package/scripts/topo-chip-pin-mirror-test.mjs +113 -0
- package/scripts/topo-chrome-a11y-test.mjs +107 -0
- package/scripts/topo-chrome-zoom-level-pop-test.mjs +122 -0
- package/scripts/topo-chrome-zoom-pop-test.mjs +130 -0
- package/scripts/topo-click-ripple-test.mjs +66 -0
- package/scripts/topo-cmdk-edge-clear-test.mjs +109 -0
- package/scripts/topo-cmdk-layout-view-test.mjs +108 -0
- package/scripts/topo-cmdk-pin-test.mjs +123 -0
- package/scripts/topo-cmdk-vendor-clear-test.mjs +133 -0
- package/scripts/topo-cmdk-vendor-pin-test.mjs +103 -0
- package/scripts/topo-cwd-tooltip-test.mjs +64 -0
- package/scripts/topo-dblclick-reset-test.mjs +74 -0
- package/scripts/topo-discrete-zoom-smooth-test.mjs +125 -0
- package/scripts/topo-dispatch-pulse-test.mjs +83 -0
- package/scripts/topo-edge-badge-click-pin-test.mjs +94 -0
- package/scripts/topo-edge-badge-hot-test.mjs +115 -0
- package/scripts/topo-edge-badge-hover-test.mjs +85 -0
- package/scripts/topo-edge-badge-lift-test.mjs +150 -0
- package/scripts/topo-edge-badge-ripple-test.mjs +111 -0
- package/scripts/topo-edge-badge-stroke-transition-test.mjs +118 -0
- package/scripts/topo-edge-count-badge-test.mjs +86 -0
- package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
- package/scripts/topo-edge-fadein-test.mjs +106 -0
- package/scripts/topo-edge-focus-test.mjs +113 -0
- package/scripts/topo-edge-hover-test.mjs +93 -0
- package/scripts/topo-edge-pill-hot-test.mjs +129 -0
- package/scripts/topo-edge-pill-test.mjs +105 -0
- package/scripts/topo-edge-stroke-transition-test.mjs +132 -0
- package/scripts/topo-edge-tooltip-test.mjs +70 -0
- package/scripts/topo-endpoint-ring-fade-test.mjs +132 -0
- package/scripts/topo-endpoint-ring-test.mjs +93 -0
- package/scripts/topo-esc-clears-pin-test.mjs +98 -0
- package/scripts/topo-filter-pill-aliases-test.mjs +121 -0
- package/scripts/topo-filter-pill-body-click-test.mjs +112 -0
- package/scripts/topo-filter-pill-count-test.mjs +110 -0
- package/scripts/topo-filter-pill-mobile-test.mjs +90 -0
- package/scripts/topo-fit-key-test.mjs +106 -0
- package/scripts/topo-freshness-chip-test.mjs +74 -0
- package/scripts/topo-freshness-chip-transition-test.mjs +80 -0
- package/scripts/topo-fullscreen-active-test.mjs +115 -0
- package/scripts/topo-group-box-pinned-state-test.mjs +95 -0
- package/scripts/topo-group-count-test.mjs +68 -0
- package/scripts/topo-group-hover-test.mjs +82 -0
- package/scripts/topo-group-label-a11y-test.mjs +113 -0
- package/scripts/topo-group-label-hover-test.mjs +119 -0
- package/scripts/topo-group-label-tint-test.mjs +99 -0
- package/scripts/topo-group-label-title-test.mjs +83 -0
- package/scripts/topo-group-pin-test.mjs +119 -0
- package/scripts/topo-group-status-pips-test.mjs +103 -0
- package/scripts/topo-groupbox-fadein-test.mjs +95 -0
- package/scripts/topo-groupbox-hover-lift-test.mjs +127 -0
- package/scripts/topo-groupbox-live-test.mjs +100 -0
- package/scripts/topo-groupbox-march-busyness-test.mjs +155 -0
- package/scripts/topo-groupbox-test.mjs +4 -2
- package/scripts/topo-help-overlay-r109-test.mjs +67 -0
- package/scripts/topo-help-overlay-r118-test.mjs +69 -0
- package/scripts/topo-help-overlay-r123-test.mjs +69 -0
- package/scripts/topo-help-overlay-r134-test.mjs +74 -0
- package/scripts/topo-help-overlay-r137-test.mjs +81 -0
- package/scripts/topo-help-overlay-r141-test.mjs +86 -0
- package/scripts/topo-help-overlay-r153-test.mjs +85 -0
- package/scripts/topo-help-overlay-r59-test.mjs +67 -0
- package/scripts/topo-help-overlay-r65-test.mjs +64 -0
- package/scripts/topo-help-overlay-r70-test.mjs +63 -0
- package/scripts/topo-help-overlay-r78-test.mjs +59 -0
- package/scripts/topo-help-overlay-r81-test.mjs +58 -0
- package/scripts/topo-help-overlay-r87-test.mjs +64 -0
- package/scripts/topo-help-overlay-r91-test.mjs +65 -0
- package/scripts/topo-hub-a11y-test.mjs +112 -0
- package/scripts/topo-hub-busyness-test.mjs +100 -0
- package/scripts/topo-hub-click-fit-test.mjs +137 -0
- package/scripts/topo-hub-fadein-test.mjs +88 -0
- package/scripts/topo-hub-hover-lift-test.mjs +101 -0
- package/scripts/topo-hub-hover-ring-test.mjs +82 -0
- package/scripts/topo-hub-title-test.mjs +93 -0
- package/scripts/topo-hub-working-count-test.mjs +99 -0
- package/scripts/topo-ingroup-edge-test.mjs +103 -0
- package/scripts/topo-label-lift-test.mjs +119 -0
- package/scripts/topo-label-scale-test.mjs +78 -0
- package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
- package/scripts/topo-layout-key-test.mjs +91 -0
- package/scripts/topo-layout-switch-crossfade-test.mjs +126 -0
- package/scripts/topo-layout-toggle-test.mjs +131 -0
- package/scripts/topo-legend-count-test.mjs +88 -0
- package/scripts/topo-legend-hover-test.mjs +113 -0
- package/scripts/topo-legend-panel-header-test.mjs +83 -0
- package/scripts/topo-legend-pin-ring-fade-test.mjs +112 -0
- package/scripts/topo-legend-pin-test.mjs +128 -0
- package/scripts/topo-legend-row-hover-lift-test.mjs +109 -0
- package/scripts/topo-legend-row-tint-test.mjs +95 -0
- package/scripts/topo-legend-row-title-test.mjs +102 -0
- package/scripts/topo-minimap-a11y-test.mjs +101 -0
- package/scripts/topo-minimap-test.mjs +68 -0
- package/scripts/topo-mobile-chips-test.mjs +103 -0
- package/scripts/topo-node-a11y-test.mjs +107 -0
- package/scripts/topo-node-hover-lift-test.mjs +85 -0
- package/scripts/topo-node-label-card-elevation-test.mjs +157 -0
- package/scripts/topo-node-stroke-transition-test.mjs +103 -0
- package/scripts/topo-node-title-peers-test.mjs +107 -0
- package/scripts/topo-node-title-test.mjs +101 -0
- package/scripts/topo-nodesize-active-hover-test.mjs +109 -0
- package/scripts/topo-nodesize-crossfade-test.mjs +141 -0
- package/scripts/topo-offline-dim-test.mjs +70 -0
- package/scripts/topo-online-chip-nav-test.mjs +143 -0
- package/scripts/topo-orbit-busyness-test.mjs +96 -0
- package/scripts/topo-overlap-test.mjs +32 -5
- package/scripts/topo-pan-cursor-test.mjs +62 -0
- package/scripts/topo-panel-elevation-test.mjs +76 -0
- package/scripts/topo-panel-fadein-test.mjs +102 -0
- package/scripts/topo-panel-hot-tail-fade-test.mjs +124 -0
- package/scripts/topo-panel-hover-elevation-test.mjs +118 -0
- package/scripts/topo-particle-stagger-test.mjs +82 -0
- package/scripts/topo-pin-intersection-empty-test.mjs +129 -0
- package/scripts/topo-pin-intersection-test.mjs +121 -0
- package/scripts/topo-pin-mirror-transition-test.mjs +101 -0
- package/scripts/topo-pin-persistence-test.mjs +105 -0
- package/scripts/topo-popover-meta-test.mjs +78 -0
- package/scripts/topo-pressure-bar-test.mjs +69 -0
- package/scripts/topo-pressure-hover-test.mjs +123 -0
- package/scripts/topo-pressure-pin-test.mjs +119 -0
- package/scripts/topo-pressure-seg-tooltip-test.mjs +98 -0
- package/scripts/topo-pressure-seg-transition-test.mjs +107 -0
- package/scripts/topo-pulse-rate-test.mjs +71 -0
- package/scripts/topo-recent-panel-count-freshness-test.mjs +109 -0
- package/scripts/topo-recent-panel-count-test.mjs +72 -0
- package/scripts/topo-recent-panel-hot-header-test.mjs +132 -0
- package/scripts/topo-recent-panel-more-nav-test.mjs +127 -0
- package/scripts/topo-recent-panel-more-test.mjs +106 -0
- package/scripts/topo-recent-row-count-fill-test.mjs +129 -0
- package/scripts/topo-recent-row-freshness-test.mjs +102 -0
- package/scripts/topo-recent-row-hot-test.mjs +110 -0
- package/scripts/topo-recent-row-hover-lift-test.mjs +128 -0
- package/scripts/topo-recent-row-hover-test.mjs +122 -0
- package/scripts/topo-recent-row-pin-test.mjs +126 -0
- package/scripts/topo-recent-row-tint-test.mjs +91 -0
- package/scripts/topo-recent-row-title-test.mjs +112 -0
- package/scripts/topo-recent-row-ts-alpha-test.mjs +100 -0
- package/scripts/topo-recent-row-ts-test.mjs +95 -0
- package/scripts/topo-recent-row-typography-test.mjs +119 -0
- package/scripts/topo-recent-signal-empty-hint-test.mjs +73 -0
- package/scripts/topo-recent-signal-empty-test.mjs +63 -0
- package/scripts/topo-reduced-motion-test.mjs +77 -0
- package/scripts/topo-reset-spin-test.mjs +120 -0
- package/scripts/topo-spoke-busyness-test.mjs +110 -0
- package/scripts/topo-status-chip-hover-test.mjs +101 -0
- package/scripts/topo-status-chip-tooltip-test.mjs +95 -0
- package/scripts/topo-svg-focus-test.mjs +112 -0
- package/scripts/topo-sweep-busyness-test.mjs +94 -0
- package/scripts/topo-tier-ring-fadein-test.mjs +107 -0
- package/scripts/topo-tier-ring-occupancy-test.mjs +87 -0
- package/scripts/topo-tier-ring-test.mjs +69 -0
- package/scripts/topo-tier-ring-tint-test.mjs +110 -0
- package/scripts/topo-tier-stagger-test.mjs +89 -0
- package/scripts/topo-tz-ghost-test.mjs +69 -0
- package/scripts/topo-vendor-chip-hover-test.mjs +111 -0
- package/scripts/topo-vendor-letter-tooltip-test.mjs +92 -0
- package/scripts/topo-vendor-pill-test.mjs +125 -0
- package/scripts/topo-vendor-pin-test.mjs +125 -0
- package/scripts/topo-view-smooth-test.mjs +131 -0
- package/scripts/topo-wheel-zoom-test.mjs +79 -0
- package/scripts/topo-working-chip-pin-test.mjs +154 -0
- package/scripts/topo-working-halo-breath-test.mjs +86 -0
- package/scripts/topo-zoom-keys-test.mjs +89 -0
- package/.next/static/chunks/0-7iowzu4.798.js +0 -1
- package/.next/static/chunks/02t5jr4shh.hy.js +0 -1
- package/.next/static/chunks/076u9wjiilxzg.js +0 -1
- package/.next/static/chunks/07hlou5cndq1b.js +0 -1
- package/.next/static/chunks/09nd.t_hd.y6t.css +0 -2
- package/.next/static/chunks/0_qfg5s5uzs7..js +0 -7
- package/.next/static/chunks/0elalb9n_.~di.js +0 -1
- package/.next/static/chunks/0fqy_0wj_1xa7.js +0 -7
- package/.next/static/chunks/0j_q__o8p6g83.js +0 -1
- package/.next/static/chunks/0jl6_w50zh4l5.js +0 -1
- package/.next/static/chunks/0lw~4lr23sl-r.js +0 -1
- package/.next/static/chunks/0r4q73.-1bkyl.js +0 -1
- package/.next/static/chunks/0r_c8eccg3z2o.js +0 -1
- package/.next/static/chunks/10oi7l-tycgl9.js +0 -1
- package/.next/static/chunks/11uk1izuv3~0t.js +0 -1
- /package/.next/static/{kZOwEPGbIfBUUE4MFGvHz → IZ83xXmRRznWkvPwyxx-z}/_buildManifest.js +0 -0
- /package/.next/static/{kZOwEPGbIfBUUE4MFGvHz → IZ83xXmRRznWkvPwyxx-z}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{kZOwEPGbIfBUUE4MFGvHz → IZ83xXmRRznWkvPwyxx-z}/_ssgManifest.js +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
4
5
|
import { Session } from './types';
|
|
5
6
|
import { aliasAvatarColors, aliasInitial } from './AliasAvatar';
|
|
6
7
|
import { ChatPopover } from './ChatPopover';
|
|
7
8
|
import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
|
|
9
|
+
import { parseHubTime, relativeAgo } from '../lib/time';
|
|
8
10
|
|
|
9
11
|
interface MessageFlow {
|
|
10
12
|
from_alias: string;
|
|
@@ -88,6 +90,83 @@ function truncate(value: string, max: number) {
|
|
|
88
90
|
return value.length > max ? `${value.slice(0, max - 1)}...` : value;
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/** Round 46 / Loop: SWR-freshness chip.
|
|
94
|
+
*
|
|
95
|
+
* TopoGraph's data refreshes every 5 s via SWR but the user has no
|
|
96
|
+
* visible signal that the canvas they're looking at is current. The
|
|
97
|
+
* flow particles and chips (R42 active-links, R43 hub) tell you when
|
|
98
|
+
* the FLEET last did something, not when the DATA last syncing. This
|
|
99
|
+
* chip ticks `live · Ns` against a 1-second interval so the operator
|
|
100
|
+
* can trust freshness without doing internal math.
|
|
101
|
+
*
|
|
102
|
+
* Owns its own setInterval so the parent topology doesn't re-render
|
|
103
|
+
* every second (only this small chip does). lastSyncRef is updated
|
|
104
|
+
* whenever the `sessions` prop reference changes — that's the SWR
|
|
105
|
+
* refresh signal. */
|
|
106
|
+
function FreshnessChip({ sessions }: { sessions: unknown }) {
|
|
107
|
+
const lastSyncRef = useRef<number>(Date.now());
|
|
108
|
+
const [now, setNow] = useState(() => Date.now());
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
// sessions reference changed → SWR just delivered a new payload.
|
|
111
|
+
lastSyncRef.current = Date.now();
|
|
112
|
+
}, [sessions]);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
115
|
+
return () => clearInterval(id);
|
|
116
|
+
}, []);
|
|
117
|
+
const sec = Math.max(0, Math.floor((now - lastSyncRef.current) / 1000));
|
|
118
|
+
// Tint the chip warmer when the data goes stale (>10s since last sync).
|
|
119
|
+
// SWR's default refreshInterval here is 5s, so anything past ~10s is
|
|
120
|
+
// either a poll miss or a network hiccup.
|
|
121
|
+
const stale = sec > 10;
|
|
122
|
+
// Round 187 / Loop: chip transitions between fresh (gray) and stale
|
|
123
|
+
// (amber) colour palettes smoothly. Pre-R187 the className swap
|
|
124
|
+
// snapped every time the stale boundary was crossed — could happen
|
|
125
|
+
// multiple times per minute on a flaky network. Adding
|
|
126
|
+
// transition-colors makes the stale-onset (gray → amber) and
|
|
127
|
+
// recovery (amber → gray) ease through the bg / text / border
|
|
128
|
+
// palette together. 300ms matches R161/R162 active-links chip
|
|
129
|
+
// freshness fade timing for visual consistency in the chip row.
|
|
130
|
+
const baseClass = "hidden sm:inline px-2.5 py-1 rounded-md font-mono border transition-colors duration-300";
|
|
131
|
+
const colorClass = stale
|
|
132
|
+
? "bg-amber-500/10 text-amber-300 border-amber-500/20"
|
|
133
|
+
: "bg-gray-500/10 text-gray-400 border-gray-500/20";
|
|
134
|
+
return (
|
|
135
|
+
<span
|
|
136
|
+
className={`${baseClass} ${colorClass}`}
|
|
137
|
+
title={stale ? `Last sync ${sec}s ago — SWR refresh may be lagging` : `Live data · refreshes every 5s · last sync ${sec}s ago`}
|
|
138
|
+
data-freshness-chip
|
|
139
|
+
data-freshness-chip-stale={stale ? 'true' : 'false'}
|
|
140
|
+
>
|
|
141
|
+
live · {sec}s
|
|
142
|
+
</span>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Round 36 / Loop: prefers-reduced-motion hook.
|
|
147
|
+
*
|
|
148
|
+
* Round 29's a11y sweep zeroed CSS animations via media query, but SVG
|
|
149
|
+
* SMIL `<animate>` / `<animateMotion>` elements aren't reachable from CSS
|
|
150
|
+
* — they need JS to opt out. This hook reads the media query and listens
|
|
151
|
+
* for changes so the topology's flow particles, pulses, click ripple
|
|
152
|
+
* and hub breath honour the OS-level preference. */
|
|
153
|
+
function useReducedMotion(): boolean {
|
|
154
|
+
const [reduced, setReduced] = useState(false);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
157
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
158
|
+
setReduced(mq.matches);
|
|
159
|
+
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
|
|
160
|
+
mq.addEventListener('change', onChange);
|
|
161
|
+
return () => mq.removeEventListener('change', onChange);
|
|
162
|
+
}, []);
|
|
163
|
+
return reduced;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Round 38 / Loop: parseHubTime + relativeAgo factored to app/lib/time.ts
|
|
167
|
+
// — the Round 35 fix lives there now alongside the Round 37 mirror so
|
|
168
|
+
// any future TZ-safe parse update happens in one place.
|
|
169
|
+
|
|
91
170
|
/** Round 12 / Loop: status trio audit.
|
|
92
171
|
*
|
|
93
172
|
* Each (status × theme) cell returns a {primary, halo, text} trio. The trio
|
|
@@ -354,9 +433,16 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
|
|
|
354
433
|
export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
|
|
355
434
|
const theme = useTheme();
|
|
356
435
|
const isLight = theme === 'light';
|
|
436
|
+
const reducedMotion = useReducedMotion();
|
|
357
437
|
const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
|
|
358
438
|
const brand = useBrand();
|
|
359
439
|
const isIntern = brand === 'intern';
|
|
440
|
+
// R133: Next.js client-router for the recent-signal panel "+N more"
|
|
441
|
+
// navigation. TopoGraph hasn't needed routing before — every other
|
|
442
|
+
// affordance composes back into the canvas's own state — but the
|
|
443
|
+
// truncated-flow footer is the one place where the user logically
|
|
444
|
+
// wants to leave: "show me the rest of the flows" → /messages.
|
|
445
|
+
const router = useRouter();
|
|
360
446
|
const [messages, setMessages] = useState<MessageFlow[]>([]);
|
|
361
447
|
// Issue #87: ring | grid layout toggle. Ring is the tiered-radial default;
|
|
362
448
|
// grid arranges nodes in an N×M grid (better for 30+ nodes). Persisted to
|
|
@@ -369,11 +455,28 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
369
455
|
if (saved === 'grid' || saved === 'ring') setLayout(saved);
|
|
370
456
|
} catch {}
|
|
371
457
|
}, []);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
458
|
+
// Round 170 / Loop: layout toggle (Ring ↔ Grid) used to teleport
|
|
459
|
+
// every node from its old position to its new one in one paint
|
|
460
|
+
// frame — the most jarring single user action left on the
|
|
461
|
+
// canvas. Solution: dim the viewport <g> to ~45% opacity for
|
|
462
|
+
// the duration of the swap so the eye reads it as a soft
|
|
463
|
+
// crossfade-blink rather than a hard teleport. layoutSwitching
|
|
464
|
+
// is a one-shot flag; the inline style on the viewport <g>
|
|
465
|
+
// reads it and lerps opacity 1 → 0.45 → 1 across the swap.
|
|
466
|
+
// Auto-clears after 400ms (covers fade-down 250ms + buffer for
|
|
467
|
+
// React to commit the new layout's positions). Same pattern as
|
|
468
|
+
// R168's smoothView arming but on a different visual axis
|
|
469
|
+
// (opacity, not transform).
|
|
470
|
+
const [layoutSwitching, setLayoutSwitching] = useState(false);
|
|
471
|
+
const toggleLayout = () => {
|
|
472
|
+
setLayoutSwitching(true);
|
|
473
|
+
setTimeout(() => setLayoutSwitching(false), 400);
|
|
474
|
+
setLayout(prev => {
|
|
475
|
+
const next = prev === 'ring' ? 'grid' : 'ring';
|
|
476
|
+
try { localStorage.setItem('anet-topo-layout', next); } catch {}
|
|
477
|
+
return next;
|
|
478
|
+
});
|
|
479
|
+
};
|
|
377
480
|
// Issue #113: node size scale (Vincent 4727 — nodes too big / crowded at
|
|
378
481
|
// ~22 nodes). S/M/L → 0.7/0.84/1.0; default M (one notch down from the old
|
|
379
482
|
// fixed size). Persisted like the layout toggle.
|
|
@@ -384,7 +487,24 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
384
487
|
if (saved === 0.7 || saved === 0.84 || saved === 1) setNodeScale(saved);
|
|
385
488
|
} catch {}
|
|
386
489
|
}, []);
|
|
490
|
+
// Round 171 / Loop: nodeSize change picks up the R170 crossfade
|
|
491
|
+
// pattern. Clicking S/M/L in the chrome re-derives every node's
|
|
492
|
+
// radius + label sizing + (in grid layout) cell spacing — a
|
|
493
|
+
// wholesale visual shift. Pre-R171 the resize snapped in one
|
|
494
|
+
// paint frame; with this flag the viewport <g> opacity dims to
|
|
495
|
+
// 0.45 for ~400ms, masking the redraw as a soft blink (same
|
|
496
|
+
// vocabulary R170 uses for layout toggle). Bails early when
|
|
497
|
+
// the picked scale matches the current one — clicking the
|
|
498
|
+
// already-active button shouldn't fire a fade. Composes with
|
|
499
|
+
// R170 layoutSwitching via `||` in the opacity expression; both
|
|
500
|
+
// flags drive the same opacity transition but expose distinct
|
|
501
|
+
// data-* attributes so tests can disambiguate which gesture
|
|
502
|
+
// armed the crossfade.
|
|
503
|
+
const [nodeSizeSwitching, setNodeSizeSwitching] = useState(false);
|
|
387
504
|
const pickNodeScale = (v: number) => {
|
|
505
|
+
if (v === nodeScale) return;
|
|
506
|
+
setNodeSizeSwitching(true);
|
|
507
|
+
setTimeout(() => setNodeSizeSwitching(false), 400);
|
|
388
508
|
setNodeScale(v);
|
|
389
509
|
try { localStorage.setItem('anet-topo-nodescale', String(v)); } catch {}
|
|
390
510
|
};
|
|
@@ -415,6 +535,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
415
535
|
activeAliases,
|
|
416
536
|
groupKeys,
|
|
417
537
|
groupBoxes,
|
|
538
|
+
gridContentBottom,
|
|
418
539
|
} = useMemo(() => {
|
|
419
540
|
const sseCount = (s: { alias: string; network_id?: string }) =>
|
|
420
541
|
(s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
|
|
@@ -424,17 +545,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
424
545
|
// index, so contiguous-in-array becomes contiguous-in-ring, i.e.
|
|
425
546
|
// each team visually clusters. localeCompare keeps CJK ordering sane.
|
|
426
547
|
const byAlias = (a: Session, b: Session) => a.alias.localeCompare(b.alias);
|
|
427
|
-
// #112 umbrella: ghost age-out — an offline node not seen
|
|
428
|
-
//
|
|
548
|
+
// #112 umbrella: ghost age-out — an offline node not seen recently is
|
|
549
|
+
// almost certainly a deleted node the server `/api/status` still
|
|
429
550
|
// returns (#74 root cause is server-side; this is the dashboard-side
|
|
430
551
|
// fallback so stale ghosts stop cluttering the topology). A missing
|
|
431
552
|
// last_seen_at is kept (conservative — could be a legitimately new node).
|
|
432
|
-
|
|
553
|
+
// Round 27 / P0: the 24h threshold was too lenient — Vincent's preview.29
|
|
554
|
+
// screenshot showed B站马 nodes deleted ~4 h earlier still visible. A
|
|
555
|
+
// healthy agent heartbeats every few seconds; if it's been silent for
|
|
556
|
+
// an hour it's effectively gone. 1 h gives a fresh disconnect time to
|
|
557
|
+
// come back while removing dead nodes well within an operator session.
|
|
558
|
+
const GHOST_MS = 60 * 60 * 1000;
|
|
433
559
|
const now = Date.now();
|
|
434
560
|
const isGhost = (s: Session) => {
|
|
435
561
|
if (s.status !== 'offline' || sseCount(s) || !s.last_seen_at) return false;
|
|
436
|
-
|
|
437
|
-
|
|
562
|
+
// Round 35 / Loop: parseHubTime normalises SQL-style timestamps to UTC
|
|
563
|
+
// before parsing so non-UTC browsers don't see a phantom 8-hour skew
|
|
564
|
+
// and ghost freshly-disconnected nodes.
|
|
565
|
+
const t = parseHubTime(s.last_seen_at);
|
|
566
|
+
return t !== null && now - t > GHOST_MS;
|
|
438
567
|
};
|
|
439
568
|
const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
|
|
440
569
|
const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s) && !isGhost(s)).sort(byAlias);
|
|
@@ -497,11 +626,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
497
626
|
// padding always fit and stacked group boxes never touch; past the
|
|
498
627
|
// floor the grid overflows and zoom/pan handles it.
|
|
499
628
|
const GROUP_TOP = nodeR + 20;
|
|
500
|
-
//
|
|
501
|
-
// (
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
629
|
+
// Round 27 / P0 (Vincent screenshot preview.29): dense plain-text
|
|
630
|
+
// node labels (`pos.y + radius + denseDrop`, with a 3 px containerBg
|
|
631
|
+
// stroke halo for readability) at the BOTTOM row of band N would
|
|
632
|
+
// paint OVER the start of band N+1's group label, creating the
|
|
633
|
+
// "blueleap → eleap", "agent-network-dashboard → t-network / board"
|
|
634
|
+
// visual chopping. Geometry of the collision:
|
|
635
|
+
// dense label visual bottom = node_N.y + radius + denseDrop + halo
|
|
636
|
+
// group label glyph top = boxY + 4 (text y=boxY+14, ~10px ascent)
|
|
637
|
+
// = node_N+1.y - GROUP_TOP + 4
|
|
638
|
+
// node_N+1.y - node_N.y = cellH for consecutive rows, so no-collide:
|
|
639
|
+
// cellH ≥ radius + denseDrop + halo + GROUP_TOP - 4 + buffer
|
|
640
|
+
// With halo=3 buffer=4: cellH ≥ nodeR + denseDrop + GROUP_TOP + 3.
|
|
641
|
+
const denseDrop = nodeScale < 0.8 ? 12 : 14;
|
|
642
|
+
const cellH = Math.max(
|
|
643
|
+
2 * nodeR + 22, // node + dense label within band
|
|
644
|
+
GROUP_TOP + 12, // group-label band + box padding
|
|
645
|
+
nodeR + denseDrop + GROUP_TOP + 3, // round-27 dense↔group label clearance
|
|
646
|
+
Math.min(100, (gy1 - gy0) / totalRows),
|
|
647
|
+
);
|
|
505
648
|
|
|
506
649
|
// Pass 2 — place each band's members.
|
|
507
650
|
for (const band of bands) {
|
|
@@ -532,14 +675,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
532
675
|
const xs = pts.map(p => p.x);
|
|
533
676
|
const ys = pts.map(p => p.y);
|
|
534
677
|
const minX = Math.min(...xs), minY = Math.min(...ys);
|
|
678
|
+
// Round 58 / Loop: per-group status mix for the label pip strip.
|
|
679
|
+
// Working = status==='working'. Idle = online but not working.
|
|
680
|
+
// Offline = !isOnline (either status==='offline' AND no SSE, or
|
|
681
|
+
// ghost-purged elsewhere — but ghosts never reach groupBoxes
|
|
682
|
+
// since they're filtered out upstream). Counts feed the label
|
|
683
|
+
// tspans directly so the strip stays inside the label's bbox,
|
|
684
|
+
// preserving the node↔label overlap-test guarantee from R19.
|
|
685
|
+
let w = 0, i = 0, o = 0;
|
|
686
|
+
for (const s of band.members) {
|
|
687
|
+
const isOn = s.status !== 'offline' || !!sseCount(s);
|
|
688
|
+
if (s.status === 'working') w++;
|
|
689
|
+
else if (isOn) i++;
|
|
690
|
+
else o++;
|
|
691
|
+
}
|
|
535
692
|
return {
|
|
536
693
|
key: band.members.length ? groupKeys[band.members[0].alias] : '',
|
|
694
|
+
count: band.members.length,
|
|
695
|
+
statuses: { working: w, idle: i, offline: o },
|
|
537
696
|
x: minX - GROUP_PAD,
|
|
538
697
|
y: minY - GROUP_TOP,
|
|
539
698
|
w: Math.max(...xs) - minX + GROUP_PAD * 2,
|
|
540
699
|
h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
|
|
541
700
|
};
|
|
542
701
|
});
|
|
702
|
+
// Round 28 / Loop: surface the grid's natural content bottom so the
|
|
703
|
+
// mount effect can auto-fit zoom when the layout would overflow the
|
|
704
|
+
// viewBox. = bottom of the last node row + its label drop + a small
|
|
705
|
+
// breathing buffer. Round 27's cellH bump makes this overflow more
|
|
706
|
+
// common (30-node fleets reach ~774 px, viewBox is 680).
|
|
707
|
+
const gridContentBottom = gy0 + totalRows * cellH + 8;
|
|
543
708
|
return {
|
|
544
709
|
onlineNodes: online,
|
|
545
710
|
offlineNodes: offline,
|
|
@@ -548,6 +713,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
548
713
|
activeAliases: active,
|
|
549
714
|
groupKeys,
|
|
550
715
|
groupBoxes,
|
|
716
|
+
gridContentBottom,
|
|
551
717
|
};
|
|
552
718
|
}
|
|
553
719
|
|
|
@@ -629,7 +795,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
629
795
|
groupKeys,
|
|
630
796
|
// #111: group boxes are a grid-layout feature only — radially scattered
|
|
631
797
|
// ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
|
|
632
|
-
groupBoxes: [] as { key: string; x: number; y: number; w: number; h: number }[],
|
|
798
|
+
groupBoxes: [] as { key: string; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
|
|
799
|
+
// ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
|
|
800
|
+
gridContentBottom: 0,
|
|
633
801
|
};
|
|
634
802
|
}, [messages, sessions, sseSessions, layout, nodeScale]);
|
|
635
803
|
|
|
@@ -653,10 +821,217 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
653
821
|
// label cards. 16 ≈ where the triple-tier rings start to crowd.
|
|
654
822
|
const denseLayout = onlineNodes.length + offlineNodes.length > 16;
|
|
655
823
|
const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
|
|
824
|
+
// Round 86 / Loop: pointer-over-label preview. R63 wired the group
|
|
825
|
+
// label to click-pin but hover gave no preview — the same hover/click
|
|
826
|
+
// gap R83 closed for pressure-bar segments. This state lets the
|
|
827
|
+
// label trigger the same dim mechanism a node hover already drives,
|
|
828
|
+
// independently of whether the cursor happens to be over a member
|
|
829
|
+
// node. ORs into hoveredGroup below so the existing derivation logic
|
|
830
|
+
// (which composes with hoveredAlias, activeGroup, R85 marching ants)
|
|
831
|
+
// keeps working unchanged.
|
|
832
|
+
const [hoveredGroupLabel, setHoveredGroupLabel] = useState<string | null>(null);
|
|
656
833
|
// Round 8 / Loop: which group is currently focused. Nodes outside this
|
|
657
834
|
// group + other group boxes fade so the eye locks onto the team you're
|
|
658
835
|
// pointing at. Singletons use their own alias as the group key.
|
|
659
|
-
const hoveredGroup =
|
|
836
|
+
const hoveredGroup = hoveredGroupLabel
|
|
837
|
+
?? (hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null);
|
|
838
|
+
// Round 63 / Loop: sticky group focus. R8 dims non-group nodes while
|
|
839
|
+
// a member is hovered, but releasing the hover lets the focus fade.
|
|
840
|
+
// Clicking the group label pins the group key here; activeGroup =
|
|
841
|
+
// hoveredGroup ?? pinnedGroup so hover transiently overrides the
|
|
842
|
+
// pin (handy for spot-comparing teams). Same compose pattern as
|
|
843
|
+
// R60/R61 pinnedStatus.
|
|
844
|
+
const [pinnedGroup, setPinnedGroup] = useState<string | null>(() => {
|
|
845
|
+
// R66: same per-tab persistence treatment as pinnedStatus above.
|
|
846
|
+
// The group key is opaque text (could be any prefix), so the init
|
|
847
|
+
// reader can't validate it against a known enum — it just reads
|
|
848
|
+
// whatever's stored. A useEffect below clears it if the value no
|
|
849
|
+
// longer matches any current group (sessions changed since save).
|
|
850
|
+
if (typeof window === 'undefined') return null;
|
|
851
|
+
try { return sessionStorage.getItem('anet-topo-pinned-group'); } catch { return null; }
|
|
852
|
+
});
|
|
853
|
+
const activeGroup = hoveredGroup ?? pinnedGroup;
|
|
854
|
+
// R66: sync pinnedGroup into sessionStorage when it changes; clear
|
|
855
|
+
// it if it no longer matches any current group (the session set
|
|
856
|
+
// can change between loads). The pinnedStatus sync sits next to
|
|
857
|
+
// its declaration further down.
|
|
858
|
+
useEffect(() => {
|
|
859
|
+
try {
|
|
860
|
+
if (pinnedGroup) sessionStorage.setItem('anet-topo-pinned-group', pinnedGroup);
|
|
861
|
+
else sessionStorage.removeItem('anet-topo-pinned-group');
|
|
862
|
+
} catch {}
|
|
863
|
+
}, [pinnedGroup]);
|
|
864
|
+
useEffect(() => {
|
|
865
|
+
if (!pinnedGroup) return;
|
|
866
|
+
const known = new Set(Object.values(groupKeys));
|
|
867
|
+
if (!known.has(pinnedGroup)) setPinnedGroup(null);
|
|
868
|
+
}, [pinnedGroup, groupKeys]);
|
|
869
|
+
// Round 49 / Loop: reverse-direction of R40's edge-on-node-hover linkage.
|
|
870
|
+
// R48 widened the flow hitbox to 16 px, so edges are precise enough to
|
|
871
|
+
// serve as a state trigger. When the user hovers a flow edge, light up its
|
|
872
|
+
// two endpoint nodes and dim the rest — "who is this edge between" becomes
|
|
873
|
+
// visible without reading the tooltip. The set is the link's two aliases
|
|
874
|
+
// (null when no edge hovered); node opacity composes this after inFocus.
|
|
875
|
+
const [hoveredEdgeKey, setHoveredEdgeKey] = useState<string | null>(null);
|
|
876
|
+
// Round 116 / Loop: sticky variant of hoveredEdgeKey. R56 made the
|
|
877
|
+
// recent-signal rows brighten the matching edge on hover, but the
|
|
878
|
+
// filter released on mouseleave — no way to lock a flow for a
|
|
879
|
+
// closer look. Click-to-pin closes that gap, matching the
|
|
880
|
+
// established hover→pin idiom (R60 status, R61 legend, R63 group,
|
|
881
|
+
// R88 vendor). activeEdgeKey = hoveredEdgeKey ?? pinnedEdgeKey
|
|
882
|
+
// below so hover still wins for spot-comparison while a pin is set.
|
|
883
|
+
const [pinnedEdgeKey, setPinnedEdgeKey] = useState<string | null>(null);
|
|
884
|
+
const activeEdgeKey = hoveredEdgeKey ?? pinnedEdgeKey;
|
|
885
|
+
// Round 77 / Loop: hovering the "N active links" header chip globally
|
|
886
|
+
// brightens every flow edge (1.5× opacity). Transient affordance — the
|
|
887
|
+
// chip already says HOW MANY active flows exist; this answers WHERE
|
|
888
|
+
// they are in one glance, without the user scanning the canvas for
|
|
889
|
+
// moving particles. Reset on mouse leave.
|
|
890
|
+
const [hoveredActiveLinks, setHoveredActiveLinks] = useState(false);
|
|
891
|
+
// Round 115 / Loop: hub-center hover state. R52 made the hub
|
|
892
|
+
// clickable (fit view) + cursor:pointer, but there was no
|
|
893
|
+
// hover-time visual feedback — the click target felt guessed at
|
|
894
|
+
// rather than confirmed. This state drives a subtle hint ring on
|
|
895
|
+
// hover so users see the affordance before committing the click.
|
|
896
|
+
const [hoveredHub, setHoveredHub] = useState(false);
|
|
897
|
+
// R133: hover state for the recent-signal panel's "+N more flows"
|
|
898
|
+
// navigation footer. Drives the on-hover opacity boost + underline
|
|
899
|
+
// that signals interactivity, mirroring the hoveredHub idiom above.
|
|
900
|
+
const [hoveredRecentMore, setHoveredRecentMore] = useState(false);
|
|
901
|
+
// R135: panel-wide hover-elevation. The recent-signal + legend
|
|
902
|
+
// panels both already host clickable rows (R56/R116 recent rows,
|
|
903
|
+
// R55/R61 legend rows) and a clickable footer (R133), so the
|
|
904
|
+
// chrome itself is interactive territory. Drop-shadow boost on
|
|
905
|
+
// mouseenter says "this whole panel is alive" — matches the R18
|
|
906
|
+
// KPI-card-hover idiom from the Overview page. Single state for
|
|
907
|
+
// both panels since they don't overlap; null when neither hovered.
|
|
908
|
+
const [hoveredPanel, setHoveredPanel] = useState<'recent' | 'legend' | null>(null);
|
|
909
|
+
// Round 80 / Loop: vendor-letter hover in the distribution chip. The
|
|
910
|
+
// chip already names vendor mix (`C:5 G:3 ?:1`); hover a letter and
|
|
911
|
+
// every node from OTHER vendors dims. Surfaces the breakdown spatially
|
|
912
|
+
// without inventing a new pin slot. Stores the vendor `initial`
|
|
913
|
+
// (single char or "?") that matches the tally key.
|
|
914
|
+
const [hoveredVendor, setHoveredVendor] = useState<string | null>(null);
|
|
915
|
+
// Round 88 / Loop: vendor filter pin. R80 added hover-to-dim on the
|
|
916
|
+
// vendor letters but the filter released as soon as the cursor left
|
|
917
|
+
// — vendor was the only filter dimension without a sticky variant
|
|
918
|
+
// (R60 status, R63 group, R69 Cmd+K all support pin). This state
|
|
919
|
+
// closes the gap. Same pattern as pinnedStatus / pinnedGroup:
|
|
920
|
+
// per-tab sessionStorage persistence, Esc clears, activeVendor =
|
|
921
|
+
// hoveredVendor ?? pinnedVendor so hover still wins for spot-
|
|
922
|
+
// comparison while a pin is active.
|
|
923
|
+
const [pinnedVendor, setPinnedVendor] = useState<string | null>(() => {
|
|
924
|
+
if (typeof window === 'undefined') return null;
|
|
925
|
+
try { return sessionStorage.getItem('anet-topo-pinned-vendor'); } catch { return null; }
|
|
926
|
+
});
|
|
927
|
+
const activeVendor = hoveredVendor ?? pinnedVendor;
|
|
928
|
+
useEffect(() => {
|
|
929
|
+
try {
|
|
930
|
+
if (pinnedVendor) sessionStorage.setItem('anet-topo-pinned-vendor', pinnedVendor);
|
|
931
|
+
else sessionStorage.removeItem('anet-topo-pinned-vendor');
|
|
932
|
+
} catch {}
|
|
933
|
+
}, [pinnedVendor]);
|
|
934
|
+
// R89: stale-purge. If the fleet's vendor distribution changes such
|
|
935
|
+
// that a previously-pinned vendor is gone (last node of that
|
|
936
|
+
// vendor disconnected), clear the pin so the chip row doesn't
|
|
937
|
+
// show "filter: A · 0" forever. Matches the same defensive purge
|
|
938
|
+
// pattern pinnedGroup uses higher up — sessionStorage survives
|
|
939
|
+
// reloads, but a stored value that no longer matches reality
|
|
940
|
+
// would paint with an impossible filter.
|
|
941
|
+
useEffect(() => {
|
|
942
|
+
if (pinnedVendor && !vendorDist.some(v => v.initial === pinnedVendor)) {
|
|
943
|
+
setPinnedVendor(null);
|
|
944
|
+
}
|
|
945
|
+
}, [pinnedVendor, vendorDist]);
|
|
946
|
+
// Round 55 / Loop: hovering a legend status row dims nodes whose status
|
|
947
|
+
// doesn't match. The legend was passive — "what does this colour mean".
|
|
948
|
+
// Now it answers "show me all of these" the same way R8 group-focus
|
|
949
|
+
// answers "show me this team". Three values match the legend rows.
|
|
950
|
+
const [hoveredStatus, setHoveredStatus] = useState<'working' | 'idle' | 'offline' | null>(null);
|
|
951
|
+
// Round 60 / Loop: sticky variant of `hoveredStatus`. R55 only filters
|
|
952
|
+
// while the user is actively hovering the legend; for sweeping a fleet
|
|
953
|
+
// you want to LOCK the filter. Each segment of the R31 pressure bar
|
|
954
|
+
// toggles a pin — click again on the same segment to release. The node
|
|
955
|
+
// opacity formula reads `activeStatus = hoveredStatus ?? pinnedStatus`
|
|
956
|
+
// so hover transiently overrides a pin (handy for spot-comparison)
|
|
957
|
+
// without nuking it.
|
|
958
|
+
// Round 66 / Loop: pin survives a page reload via sessionStorage —
|
|
959
|
+
// per-tab not per-browser (a new tab starts clean, intentionally).
|
|
960
|
+
// The init reader validates against the known status set so a stale
|
|
961
|
+
// / corrupt value can't paint the canvas with an impossible filter.
|
|
962
|
+
const [pinnedStatus, setPinnedStatus] = useState<'working' | 'idle' | 'offline' | null>(() => {
|
|
963
|
+
if (typeof window === 'undefined') return null;
|
|
964
|
+
try {
|
|
965
|
+
const v = sessionStorage.getItem('anet-topo-pinned-status');
|
|
966
|
+
return (v === 'working' || v === 'idle' || v === 'offline') ? v : null;
|
|
967
|
+
} catch { return null; }
|
|
968
|
+
});
|
|
969
|
+
const activeStatus = hoveredStatus ?? pinnedStatus;
|
|
970
|
+
// R66: sync pinnedStatus into sessionStorage when it changes. Paired
|
|
971
|
+
// with the matching effect for pinnedGroup higher up.
|
|
972
|
+
useEffect(() => {
|
|
973
|
+
try {
|
|
974
|
+
if (pinnedStatus) sessionStorage.setItem('anet-topo-pinned-status', pinnedStatus);
|
|
975
|
+
else sessionStorage.removeItem('anet-topo-pinned-status');
|
|
976
|
+
} catch {}
|
|
977
|
+
}, [pinnedStatus]);
|
|
978
|
+
// R69: listen for Cmd+K palette pin actions. The palette can't reach
|
|
979
|
+
// this component's state directly so it dispatches a CustomEvent;
|
|
980
|
+
// we react by setting pinnedStatus / pinnedGroup. The palette also
|
|
981
|
+
// writes to sessionStorage in lockstep, so the R66 init readers
|
|
982
|
+
// would pick it up on reload — the event handler is what keeps the
|
|
983
|
+
// currently-mounted canvas in sync without one.
|
|
984
|
+
useEffect(() => {
|
|
985
|
+
const onPin = (e: Event) => {
|
|
986
|
+
const detail = (e as CustomEvent).detail || {};
|
|
987
|
+
if (detail.kind === 'clear') {
|
|
988
|
+
// R90: extend the universal clear to vendor. R69 only cleared
|
|
989
|
+
// status + group because pinnedVendor didn't exist yet; R88
|
|
990
|
+
// shipped the third pin and R90 closes the palette gap so
|
|
991
|
+
// "Clear topology filters" really means all of them.
|
|
992
|
+
// R117: extend again to pinnedEdgeKey — R116 added the 4th
|
|
993
|
+
// pin dim and this command must clear it too or the edge stays
|
|
994
|
+
// bright in the canvas while every other pill clears.
|
|
995
|
+
setPinnedStatus(null);
|
|
996
|
+
setPinnedGroup(null);
|
|
997
|
+
setPinnedVendor(null);
|
|
998
|
+
setPinnedEdgeKey(null);
|
|
999
|
+
} else if (detail.kind === 'clear-vendor') {
|
|
1000
|
+
// R90: granular vendor-only clear so power users can release
|
|
1001
|
+
// just the vendor without disturbing status/group pins.
|
|
1002
|
+
setPinnedVendor(null);
|
|
1003
|
+
} else if (detail.kind === 'clear-edge') {
|
|
1004
|
+
// R117: granular edge-only clear, mirror of R90 clear-vendor.
|
|
1005
|
+
setPinnedEdgeKey(null);
|
|
1006
|
+
} else if (detail.kind === 'status') {
|
|
1007
|
+
const v = detail.value;
|
|
1008
|
+
if (v === 'working' || v === 'idle' || v === 'offline') setPinnedStatus(v);
|
|
1009
|
+
} else if (detail.kind === 'group' && typeof detail.value === 'string') {
|
|
1010
|
+
setPinnedGroup(detail.value);
|
|
1011
|
+
} else if (detail.kind === 'vendor' && typeof detail.value === 'string') {
|
|
1012
|
+
// R108: palette pin-vendor support. R69 added pin-status, R88
|
|
1013
|
+
// added clickable vendor letters in the chip row, R90 added
|
|
1014
|
+
// granular clear-vendor — but the palette never gained a
|
|
1015
|
+
// PIN-vendor command. This branch listens for it; the matching
|
|
1016
|
+
// commands live in CommandPalette R108. The stale-purge
|
|
1017
|
+
// useEffect higher up already drops a pin whose vendor isn't
|
|
1018
|
+
// in the current distribution, so commands for an absent
|
|
1019
|
+
// vendor are harmless.
|
|
1020
|
+
setPinnedVendor(detail.value);
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
window.addEventListener('anet:topo-pin', onPin);
|
|
1024
|
+
return () => window.removeEventListener('anet:topo-pin', onPin);
|
|
1025
|
+
}, []);
|
|
1026
|
+
// R74 listener for layout + view palette commands lives below
|
|
1027
|
+
// fitView's declaration — see further down in the file.
|
|
1028
|
+
const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
|
|
1029
|
+
// R116: compose hover ?? pin so pinning a row via click keeps the
|
|
1030
|
+
// endpoint ring + edge ladder lit after mouseleave.
|
|
1031
|
+
if (!activeEdgeKey) return null;
|
|
1032
|
+
const link = flowLinks.find(l => l.key === activeEdgeKey);
|
|
1033
|
+
return link ? new Set([link.from, link.to]) : null;
|
|
1034
|
+
}, [activeEdgeKey, flowLinks]);
|
|
660
1035
|
|
|
661
1036
|
// --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
|
|
662
1037
|
// DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
|
|
@@ -673,9 +1048,44 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
673
1048
|
const viewRef = useRef(view);
|
|
674
1049
|
const dragRef = useRef({ active: false, startX: 0, startY: 0, baseX: 0, baseY: 0 });
|
|
675
1050
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
1051
|
+
// Round 184 / Loop: chrome reset button gets a one-shot icon spin
|
|
1052
|
+
// on click. resetSpinning is armed for 450ms (matches the CSS
|
|
1053
|
+
// animation duration in globals.css `.anet-reset-spin`); the SVG
|
|
1054
|
+
// icon picks up the class while armed. Same arming pattern as
|
|
1055
|
+
// R168/R169/R170/R171 smoothView flags but driving a CSS animation
|
|
1056
|
+
// instead of a CSS transition. prefers-reduced-motion blanket
|
|
1057
|
+
// override in R29 globals.css neutralises animation-duration
|
|
1058
|
+
// universally so the spin completes in 1ms when reduced motion is
|
|
1059
|
+
// requested.
|
|
1060
|
+
const [resetSpinning, setResetSpinning] = useState(false);
|
|
1061
|
+
const armResetSpin = () => {
|
|
1062
|
+
setResetSpinning(true);
|
|
1063
|
+
setTimeout(() => setResetSpinning(false), 460);
|
|
1064
|
+
};
|
|
1065
|
+
// Round 186 / Loop: chrome zoom-in / zoom-out buttons get a brief
|
|
1066
|
+
// icon pop on click — same click-feel idiom R184 added for the
|
|
1067
|
+
// reset spin, but a scale pulse rather than a rotation since +/−
|
|
1068
|
+
// icons rotating wouldn't read semantically. Tracks which button
|
|
1069
|
+
// is currently popping so only that icon picks up the class.
|
|
1070
|
+
// Arms for 240ms (CSS animation 220ms + 20ms buffer) so a quick
|
|
1071
|
+
// re-click can replay cleanly.
|
|
1072
|
+
const [chromePopping, setChromePopping] = useState<'zoom-in' | 'zoom-out' | null>(null);
|
|
1073
|
+
const popChrome = (which: 'zoom-in' | 'zoom-out') => {
|
|
1074
|
+
setChromePopping(which);
|
|
1075
|
+
setTimeout(() => setChromePopping(prev => prev === which ? null : prev), 240);
|
|
1076
|
+
};
|
|
676
1077
|
// Issue #100: singleton chat popover. One alias at a time — clicking
|
|
677
1078
|
// another node swaps the target and the conversation switches in place.
|
|
678
1079
|
const [chatAlias, setChatAlias] = useState<string | null>(null);
|
|
1080
|
+
// Round 14 / Loop: one-shot click ripple — fades outward from the clicked
|
|
1081
|
+
// node so the click registers physically before the popover snaps in.
|
|
1082
|
+
// Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
|
|
1083
|
+
// Keyed by ts so re-clicking the same node remounts the <circle> and the
|
|
1084
|
+
// SMIL <animate> replays. Cleared 600ms after click (longer than the
|
|
1085
|
+
// 500ms animation so a final frame at opacity 0 is still in the tree).
|
|
1086
|
+
const [clickRipple, setClickRipple] = useState<{
|
|
1087
|
+
ts: number; x: number; y: number; r0: number; color: string;
|
|
1088
|
+
} | null>(null);
|
|
679
1089
|
// #84: when a node is renamed while its chat popover is open, follow the
|
|
680
1090
|
// rename so the conversation keeps targeting a live alias.
|
|
681
1091
|
useEffect(() => {
|
|
@@ -706,6 +1116,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
706
1116
|
} catch {}
|
|
707
1117
|
}, []);
|
|
708
1118
|
|
|
1119
|
+
// Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
|
|
1120
|
+
// dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
|
|
1121
|
+
// 100% zoom — a new user sees the topology with its bottom rows
|
|
1122
|
+
// clipped and no hint that there's more below. Auto-fit on first
|
|
1123
|
+
// paint (and only if the user has no persisted view) sets the
|
|
1124
|
+
// initial zoom so all content fits. Subsequent zoom changes persist
|
|
1125
|
+
// and override the auto-fit on reload; explicit reset (0 key) still
|
|
1126
|
+
// goes back to 100% so the gesture's "reset to natural size" semantic
|
|
1127
|
+
// is preserved.
|
|
1128
|
+
//
|
|
1129
|
+
// Capture pre-mount persistence in a useState initializer — the
|
|
1130
|
+
// existing `persist` effect (declared below) runs on first render
|
|
1131
|
+
// with the default {1,0,0} view and writes it to localStorage before
|
|
1132
|
+
// the auto-fit effect (deps on async-arriving sessions) gets a turn.
|
|
1133
|
+
// Reading localStorage from the effect would see that write and
|
|
1134
|
+
// skip the fit. The useState snapshot fires once, before any effects.
|
|
1135
|
+
const [hadPersistedViewOnMount] = useState<boolean>(
|
|
1136
|
+
() => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
|
|
1137
|
+
);
|
|
1138
|
+
const autoFitDoneRef = useRef(false);
|
|
1139
|
+
useEffect(() => {
|
|
1140
|
+
if (autoFitDoneRef.current) return;
|
|
1141
|
+
if (hadPersistedViewOnMount) {
|
|
1142
|
+
autoFitDoneRef.current = true;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
|
|
1146
|
+
if (gridContentBottom <= VIEWBOX_H) {
|
|
1147
|
+
autoFitDoneRef.current = true; // no overflow → no fit needed
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
|
|
1151
|
+
setView({ zoom: fitZoom, x: 0, y: 0 });
|
|
1152
|
+
autoFitDoneRef.current = true;
|
|
1153
|
+
}, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
|
|
1154
|
+
|
|
709
1155
|
// persist view
|
|
710
1156
|
useEffect(() => {
|
|
711
1157
|
try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
|
|
@@ -725,6 +1171,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
725
1171
|
const svg = svgRef.current;
|
|
726
1172
|
if (!svg) return;
|
|
727
1173
|
const onWheel = (e: WheelEvent) => {
|
|
1174
|
+
// Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
|
|
1175
|
+
// wheel over the canvas keeps scrolling the page (the topo sits at
|
|
1176
|
+
// the top of /; trapping page scroll mid-page is the classic
|
|
1177
|
+
// "scroll-jail" anti-pattern). In fullscreen the canvas owns the
|
|
1178
|
+
// viewport so any wheel zooms — no page scroll to preserve. Pinch-
|
|
1179
|
+
// zoom on a trackpad surfaces as ctrlKey=true natively, which is
|
|
1180
|
+
// also what we want (zoom the canvas, not the browser).
|
|
1181
|
+
if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
|
|
728
1182
|
e.preventDefault();
|
|
729
1183
|
const rect = svg.getBoundingClientRect();
|
|
730
1184
|
const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
|
|
@@ -745,8 +1199,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
745
1199
|
};
|
|
746
1200
|
svg.addEventListener('wheel', onWheel, { passive: false });
|
|
747
1201
|
return () => svg.removeEventListener('wheel', onWheel);
|
|
748
|
-
|
|
1202
|
+
// Re-attach when the fullscreen flag toggles so the handler's
|
|
1203
|
+
// closure picks up the new value (capture-by-closure means we'd
|
|
1204
|
+
// otherwise read the stale boolean forever).
|
|
1205
|
+
}, [isFullscreen]);
|
|
749
1206
|
|
|
1207
|
+
// Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
|
|
1208
|
+
// that the canvas was actually being dragged — "grabbing" while
|
|
1209
|
+
// dragRef.current.active = true tells the hand it's moving the
|
|
1210
|
+
// viewport. Mirroring dragRef.active into state is the only way to
|
|
1211
|
+
// re-render the SVG style; the ref itself doesn't trigger renders.
|
|
1212
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
750
1213
|
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
|
751
1214
|
if (e.button !== 0) return;
|
|
752
1215
|
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
|
@@ -757,6 +1220,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
757
1220
|
baseX: viewRef.current.x,
|
|
758
1221
|
baseY: viewRef.current.y,
|
|
759
1222
|
};
|
|
1223
|
+
setIsPanning(true);
|
|
760
1224
|
};
|
|
761
1225
|
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
|
762
1226
|
const d = dragRef.current;
|
|
@@ -771,6 +1235,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
771
1235
|
const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
|
|
772
1236
|
if (!dragRef.current.active) return;
|
|
773
1237
|
dragRef.current.active = false;
|
|
1238
|
+
setIsPanning(false);
|
|
774
1239
|
try {
|
|
775
1240
|
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
|
776
1241
|
} catch {}
|
|
@@ -786,7 +1251,130 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
786
1251
|
return { zoom: nz, x: cx0 - (cx0 - prev.x) * ratio, y: cy0 - (cy0 - prev.y) * ratio };
|
|
787
1252
|
});
|
|
788
1253
|
};
|
|
789
|
-
|
|
1254
|
+
// Round 169 / Loop: discrete-zoom wrapper that arms the R168
|
|
1255
|
+
// smoothView flag before invoking zoomBy. Keyboard + / − and
|
|
1256
|
+
// the chrome zoom buttons fire once per gesture, so each
|
|
1257
|
+
// 1.2× step deserves the same 300ms glide R168 gives
|
|
1258
|
+
// reset/fit. Wheel zoom keeps calling zoomBy() directly —
|
|
1259
|
+
// every tick should respond live without lag. The arming
|
|
1260
|
+
// path is identical to resetView/fitView so all four
|
|
1261
|
+
// discrete-zoom surfaces share one timing constant.
|
|
1262
|
+
const zoomByDiscrete = (factor: number) => {
|
|
1263
|
+
setSmoothView(true);
|
|
1264
|
+
setTimeout(() => setSmoothView(false), 350);
|
|
1265
|
+
zoomBy(factor);
|
|
1266
|
+
};
|
|
1267
|
+
// Round 168 / Loop: smoothView is a one-shot flag that arms a
|
|
1268
|
+
// CSS transition on the viewport <g> transform attribute. Set
|
|
1269
|
+
// true when resetView/fitView fires; the inline style on the
|
|
1270
|
+
// viewport <g> reads this flag and conditionally applies
|
|
1271
|
+
// `transition: transform 300ms ease-out`. Auto-clears after
|
|
1272
|
+
// 350ms (just past the transition end) via setTimeout so
|
|
1273
|
+
// subsequent pan/wheel zoom stays snappy with no lag. Keeping
|
|
1274
|
+
// it as state (vs ref) so the React rerender re-applies the
|
|
1275
|
+
// style attribute synchronously when the transform also
|
|
1276
|
+
// changes — both attribute mutation and transition class
|
|
1277
|
+
// arrive in the same paint frame, which is what triggers the
|
|
1278
|
+
// browser to animate between the old and new transform values.
|
|
1279
|
+
const [smoothView, setSmoothView] = useState(false);
|
|
1280
|
+
const armSmoothView = () => {
|
|
1281
|
+
setSmoothView(true);
|
|
1282
|
+
setTimeout(() => setSmoothView(false), 350);
|
|
1283
|
+
};
|
|
1284
|
+
const resetView = () => {
|
|
1285
|
+
armSmoothView();
|
|
1286
|
+
setView({ zoom: 1, x: 0, y: 0 });
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
// Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
|
|
1290
|
+
// first-paint auto-fit effect and the keyboard handler so the math is
|
|
1291
|
+
// in one place. When content already fits at natural zoom, this is
|
|
1292
|
+
// effectively a "recenter" — `f` always lands on a known good view.
|
|
1293
|
+
// R168: arm smoothView so the transition glides instead of snapping
|
|
1294
|
+
// when the operator invokes fit-to-content via the hub click (R52),
|
|
1295
|
+
// chrome button, `f` key, or palette command.
|
|
1296
|
+
const fitView = useCallback(() => {
|
|
1297
|
+
const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
|
|
1298
|
+
? 1
|
|
1299
|
+
: Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
|
|
1300
|
+
setSmoothView(true);
|
|
1301
|
+
setTimeout(() => setSmoothView(false), 350);
|
|
1302
|
+
setView({ zoom, x: 0, y: 0 });
|
|
1303
|
+
}, [gridContentBottom]);
|
|
1304
|
+
|
|
1305
|
+
// R74: listen for layout + view palette commands. Sister to R69's
|
|
1306
|
+
// pin listener — palette dispatches a CustomEvent, the reducer here
|
|
1307
|
+
// calls toggleLayout / fitView. Sits below fitView's declaration so
|
|
1308
|
+
// the deps list resolves cleanly.
|
|
1309
|
+
useEffect(() => {
|
|
1310
|
+
const onLayout = (e: Event) => {
|
|
1311
|
+
const detail = (e as CustomEvent).detail || {};
|
|
1312
|
+
if (detail.kind === 'toggle') toggleLayout();
|
|
1313
|
+
};
|
|
1314
|
+
const onView = (e: Event) => {
|
|
1315
|
+
const detail = (e as CustomEvent).detail || {};
|
|
1316
|
+
if (detail.kind === 'fit') fitView();
|
|
1317
|
+
};
|
|
1318
|
+
window.addEventListener('anet:topo-layout', onLayout);
|
|
1319
|
+
window.addEventListener('anet:topo-view', onView);
|
|
1320
|
+
return () => {
|
|
1321
|
+
window.removeEventListener('anet:topo-layout', onLayout);
|
|
1322
|
+
window.removeEventListener('anet:topo-view', onView);
|
|
1323
|
+
};
|
|
1324
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1325
|
+
}, [fitView]);
|
|
1326
|
+
|
|
1327
|
+
// Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
|
|
1328
|
+
// Round 29 / Loop: +f to fit content.
|
|
1329
|
+
// Listen on window so the user doesn't need to focus the SVG first,
|
|
1330
|
+
// but only act when no text input has focus (otherwise typing "-" in
|
|
1331
|
+
// a chat would zoom the topology — surprising and bad). Modifier keys
|
|
1332
|
+
// pass through so Cmd/Ctrl combos still hit their owners. The button
|
|
1333
|
+
// titles below document these shortcuts so users discover them.
|
|
1334
|
+
useEffect(() => {
|
|
1335
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1336
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
1337
|
+
const ae = document.activeElement as HTMLElement | null;
|
|
1338
|
+
if (ae) {
|
|
1339
|
+
const tag = ae.tagName;
|
|
1340
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
|
|
1341
|
+
}
|
|
1342
|
+
if (e.key === '+' || e.key === '=') { zoomByDiscrete(1.2); e.preventDefault(); }
|
|
1343
|
+
else if (e.key === '-' || e.key === '_') { zoomByDiscrete(1 / 1.2); e.preventDefault(); }
|
|
1344
|
+
else if (e.key === '0') { resetView(); e.preventDefault(); }
|
|
1345
|
+
else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
|
|
1346
|
+
// Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
|
|
1347
|
+
// (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
|
|
1348
|
+
// outside that window is free for topology use.
|
|
1349
|
+
else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
|
|
1350
|
+
// Round 62 / Loop: Esc clears the R60/R61 pinned status filter so
|
|
1351
|
+
// users have a universal-cancel keyboard out. Esc on an open chat
|
|
1352
|
+
// is owned by ChatPopover (which only mounts when chatAlias is
|
|
1353
|
+
// set), so this handler is effectively scoped to "no chat open".
|
|
1354
|
+
// We additionally guard on chatAlias to be explicit — if the chat
|
|
1355
|
+
// closed mid-cycle, the pin can still be cleared on the next Esc.
|
|
1356
|
+
// R63: extends to pinnedGroup too. Clears WHATEVER pin is active
|
|
1357
|
+
// (one Esc collapses all topology pins) so the keyboard escape
|
|
1358
|
+
// route stays a single key, not "Esc maybe Esc again".
|
|
1359
|
+
else if (e.key === 'Escape' && !chatAlias && (pinnedStatus || pinnedGroup || pinnedVendor || pinnedEdgeKey)) {
|
|
1360
|
+
// R88/R116: extend the universal-cancel to vendor + edge too.
|
|
1361
|
+
// One Esc collapses every topology pin (matches R62/R63's
|
|
1362
|
+
// "single key, not Esc-maybe-Esc-again" promise).
|
|
1363
|
+
if (pinnedStatus) setPinnedStatus(null);
|
|
1364
|
+
if (pinnedGroup) setPinnedGroup(null);
|
|
1365
|
+
if (pinnedVendor) setPinnedVendor(null);
|
|
1366
|
+
if (pinnedEdgeKey) setPinnedEdgeKey(null);
|
|
1367
|
+
e.preventDefault();
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
window.addEventListener('keydown', onKey);
|
|
1371
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
1372
|
+
// zoomBy / resetView are stable wrt setView callback; fitView changes
|
|
1373
|
+
// with gridContentBottom so deps list catches it. R62 adds chatAlias
|
|
1374
|
+
// and pinnedStatus so the Escape branch reads fresh state (re-binding
|
|
1375
|
+
// the listener on these state changes is sub-ms — cheaper than refs).
|
|
1376
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1377
|
+
}, [fitView, chatAlias, pinnedStatus, pinnedGroup, pinnedVendor, pinnedEdgeKey]);
|
|
790
1378
|
|
|
791
1379
|
const toggleFullscreen = () => {
|
|
792
1380
|
const el = containerRef.current;
|
|
@@ -810,45 +1398,817 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
810
1398
|
</div>
|
|
811
1399
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
812
1400
|
{/* Issue #87: ring | grid layout toggle — segmented control,
|
|
813
|
-
persisted to localStorage anet-topo-layout.
|
|
1401
|
+
persisted to localStorage anet-topo-layout.
|
|
1402
|
+
Round 163 / Loop: bring the layout toggle into the R154
|
|
1403
|
+
chrome-button focus convention. Pre-R163 the buttons had
|
|
1404
|
+
aria-pressed + transition-colors but no focus-visible ring
|
|
1405
|
+
(browser default — invisible against dark canvas) and the
|
|
1406
|
+
inactive variant only nudged text from gray-500 → gray-400
|
|
1407
|
+
on hover with no bg tint, so the hover-to-click affordance
|
|
1408
|
+
was barely perceptible.
|
|
1409
|
+
|
|
1410
|
+
R163 closes both gaps to match R154:
|
|
1411
|
+
focus-visible:ring-2 focus-visible:ring-cyan-400/60
|
|
1412
|
+
→ keyboard users see exactly which segment is focused
|
|
1413
|
+
hover:bg-cyan-500/5 (inactive)
|
|
1414
|
+
→ mouse hover shows a faint cyan ghost of the active
|
|
1415
|
+
state, signalling 'click to switch'
|
|
1416
|
+
hover:bg-cyan-500/20 (active)
|
|
1417
|
+
→ active button responds too, says 'still clickable'
|
|
1418
|
+
data-topo-chrome-layout for testability symmetric with the
|
|
1419
|
+
R154 chrome buttons (data-topo-chrome-zoom-in / -reset /
|
|
1420
|
+
-fullscreen etc). */}
|
|
814
1421
|
<div className="inline-flex rounded-md border border-gray-500/25 overflow-hidden" role="group" aria-label="Topology layout">
|
|
815
1422
|
<button
|
|
816
1423
|
onClick={() => { if (layout !== 'ring') toggleLayout(); }}
|
|
817
1424
|
aria-pressed={layout === 'ring'}
|
|
818
|
-
|
|
1425
|
+
title="Ring layout (l to toggle)"
|
|
1426
|
+
data-topo-chrome-layout="ring"
|
|
1427
|
+
data-topo-chrome-layout-active={layout === 'ring' ? 'true' : 'false'}
|
|
1428
|
+
className={`px-2.5 py-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300 hover:bg-cyan-500/20' : 'text-gray-500 hover:text-gray-300 hover:bg-cyan-500/5'}`}
|
|
819
1429
|
>
|
|
820
1430
|
Ring
|
|
821
1431
|
</button>
|
|
822
1432
|
<button
|
|
823
1433
|
onClick={() => { if (layout !== 'grid') toggleLayout(); }}
|
|
824
1434
|
aria-pressed={layout === 'grid'}
|
|
825
|
-
|
|
1435
|
+
title="Grid layout (l to toggle)"
|
|
1436
|
+
data-topo-chrome-layout="grid"
|
|
1437
|
+
data-topo-chrome-layout-active={layout === 'grid' ? 'true' : 'false'}
|
|
1438
|
+
className={`px-2.5 py-1 border-l border-gray-500/25 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${layout === 'grid' ? 'bg-cyan-500/15 text-cyan-300 hover:bg-cyan-500/20' : 'text-gray-500 hover:text-gray-300 hover:bg-cyan-500/5'}`}
|
|
826
1439
|
>
|
|
827
1440
|
Grid
|
|
828
1441
|
</button>
|
|
829
1442
|
</div>
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1443
|
+
{/* R79: working + online count chips become hover affordances —
|
|
1444
|
+
extends R77's chip-hover pattern to status counts. Hover the
|
|
1445
|
+
working chip → setHoveredStatus('working') so the R55 dim
|
|
1446
|
+
mechanic kicks in (same as hovering the SVG legend "working"
|
|
1447
|
+
row, just a different surface for the same gesture).
|
|
1448
|
+
The online chip uses the same setHoveredStatus('idle') path
|
|
1449
|
+
when there's idle but no working — falling back to working
|
|
1450
|
+
if any working node exists; "online" without a single bucket
|
|
1451
|
+
isn't part of the R55 type set, so this chip routes to the
|
|
1452
|
+
dominant online sub-tier instead of inventing a new state.
|
|
1453
|
+
Cursor only flips when there's anything to highlight. */}
|
|
1454
|
+
{/* R82: pin-mirror. R60 pressure-bar segments visualise the
|
|
1455
|
+
pinned status via an inset boxShadow; R61 legend rows via a
|
|
1456
|
+
concentric r=8 ring; the chip-row chips next to them did
|
|
1457
|
+
not — so a user who pinned via Cmd+K (R69) or the legend
|
|
1458
|
+
had no chip-row signal that "working" was the current
|
|
1459
|
+
filter. Mirror the pressure-bar treatment here so all
|
|
1460
|
+
status surfaces sing in unison. The online chip mirrors
|
|
1461
|
+
for idle pins (working ⊆ online; the routing in
|
|
1462
|
+
onMouseEnter already treats online as the idle fallback). */}
|
|
1463
|
+
{(() => {
|
|
1464
|
+
// R113 / Loop: extend the R97-R102/R101 alias-list tooltip
|
|
1465
|
+
// sweep to the remaining chip-row affordances. R79 made
|
|
1466
|
+
// these chips hoverable; their generic "Hover to highlight"
|
|
1467
|
+
// titles never said WHICH nodes match. Compute the alias
|
|
1468
|
+
// lists once for both chips to share.
|
|
1469
|
+
const workingAliases = onlineNodes.filter(s => s.status === 'working').map(s => s.alias);
|
|
1470
|
+
const onlineAliases = onlineNodes.map(s => s.alias);
|
|
1471
|
+
const truncate = (list: string[]) => {
|
|
1472
|
+
const head = list.slice(0, 8).join(', ');
|
|
1473
|
+
const tail = list.length > 8 ? ` + ${list.length - 8} more` : '';
|
|
1474
|
+
return head + tail;
|
|
1475
|
+
};
|
|
1476
|
+
const workingTitle = workingCount === 0
|
|
1477
|
+
? undefined
|
|
1478
|
+
: pinnedStatus === 'working'
|
|
1479
|
+
? `${truncate(workingAliases)} — pinned, Esc to clear`
|
|
1480
|
+
: `${truncate(workingAliases)} — hover highlights, click to pin`;
|
|
1481
|
+
// R140: online chip title gains a "click to open /nodes" tail
|
|
1482
|
+
// when interactive. R79 made the cursor pointer-shaped but
|
|
1483
|
+
// wired nothing; R136 + R139 closed the same lie on two
|
|
1484
|
+
// sibling chips by wiring real actions. The online chip
|
|
1485
|
+
// can't pin a single status (online = working + idle,
|
|
1486
|
+
// not a single pinnedStatus value), so a pin idiom would
|
|
1487
|
+
// be semantically wrong. /nodes is the natural full-list
|
|
1488
|
+
// destination — same "click chip for the full list" idiom
|
|
1489
|
+
// the active-links chip uses (R136 → /messages).
|
|
1490
|
+
const onlineTitle = onlineNodes.length === 0
|
|
1491
|
+
? undefined
|
|
1492
|
+
: pinnedStatus === 'idle'
|
|
1493
|
+
? `${truncate(onlineAliases)} — pinned, Esc to clear`
|
|
1494
|
+
: `${truncate(onlineAliases)} — hover highlights · click to open /nodes`;
|
|
1495
|
+
return (
|
|
1496
|
+
<>
|
|
1497
|
+
<span
|
|
1498
|
+
className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20 anet-topo-chip-focus"
|
|
1499
|
+
data-working-chip
|
|
1500
|
+
data-working-chip-aliases={workingAliases.join(',')}
|
|
1501
|
+
data-pin-mirror={pinnedStatus === 'working' ? 'true' : 'false'}
|
|
1502
|
+
data-working-chip-clickable={workingCount > 0 ? 'true' : 'false'}
|
|
1503
|
+
title={workingTitle}
|
|
1504
|
+
role={workingCount > 0 ? 'button' : undefined}
|
|
1505
|
+
tabIndex={workingCount > 0 ? 0 : undefined}
|
|
1506
|
+
aria-pressed={workingCount > 0 ? (pinnedStatus === 'working') : undefined}
|
|
1507
|
+
// Round 180 / Loop: pin-mirror inset rings now ease in/out
|
|
1508
|
+
// instead of snapping. R165 added this transition to the
|
|
1509
|
+
// pressure-bar segments; R180 closes the smooth-pin-mirror
|
|
1510
|
+
// family across the three remaining chip-row pin chips
|
|
1511
|
+
// (working / online / vendor letter). The visual is small
|
|
1512
|
+
// — a 1-2 px inset double ring — but the eye catches the
|
|
1513
|
+
// pop on every pin/unpin without the ease.
|
|
1514
|
+
style={{
|
|
1515
|
+
cursor: workingCount > 0 ? 'pointer' : undefined,
|
|
1516
|
+
boxShadow: pinnedStatus === 'working' ? 'inset 0 0 0 1px #4ade80, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
|
|
1517
|
+
transition: 'box-shadow 150ms ease-out',
|
|
1518
|
+
}}
|
|
1519
|
+
onMouseEnter={() => { if (workingCount > 0) setHoveredStatus('working'); }}
|
|
1520
|
+
onMouseLeave={() => setHoveredStatus(prev => prev === 'working' ? null : prev)}
|
|
1521
|
+
// R139: the title hover-text has been promising "click to
|
|
1522
|
+
// pin" since R79 but no onClick was ever wired. The cursor:
|
|
1523
|
+
// pointer at line 1363 set up the same lie R136 fixed on
|
|
1524
|
+
// the active-links chip. Wire it now: click toggles the
|
|
1525
|
+
// status pin to 'working', composing with R60 (pressure-
|
|
1526
|
+
// bar segments) and R61 (legend rows) — three different
|
|
1527
|
+
// surfaces that all toggle the same pinnedStatus. boxShadow
|
|
1528
|
+
// pin-mirror at line 1364 already reflects the state; aria-
|
|
1529
|
+
// pressed now exposes it for screen readers too.
|
|
1530
|
+
onClick={() => {
|
|
1531
|
+
if (workingCount > 0) setPinnedStatus(prev => prev === 'working' ? null : 'working');
|
|
1532
|
+
}}
|
|
1533
|
+
onKeyDown={(e) => {
|
|
1534
|
+
if (workingCount === 0) return;
|
|
1535
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1536
|
+
e.preventDefault();
|
|
1537
|
+
setPinnedStatus(prev => prev === 'working' ? null : 'working');
|
|
1538
|
+
}
|
|
1539
|
+
}}
|
|
1540
|
+
>
|
|
1541
|
+
{workingCount} working
|
|
1542
|
+
</span>
|
|
1543
|
+
<span
|
|
1544
|
+
className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 anet-topo-chip-focus"
|
|
1545
|
+
data-online-chip
|
|
1546
|
+
data-online-chip-aliases={onlineAliases.join(',')}
|
|
1547
|
+
data-pin-mirror={pinnedStatus === 'idle' ? 'true' : 'false'}
|
|
1548
|
+
data-online-chip-clickable={onlineNodes.length > 0 ? 'true' : 'false'}
|
|
1549
|
+
title={onlineTitle}
|
|
1550
|
+
role={onlineNodes.length > 0 ? 'link' : undefined}
|
|
1551
|
+
tabIndex={onlineNodes.length > 0 ? 0 : undefined}
|
|
1552
|
+
// R180: smooth-pin-mirror family — see working chip above.
|
|
1553
|
+
style={{
|
|
1554
|
+
cursor: onlineNodes.length > 0 ? 'pointer' : undefined,
|
|
1555
|
+
boxShadow: pinnedStatus === 'idle' ? 'inset 0 0 0 1px #67e8f9, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
|
|
1556
|
+
transition: 'box-shadow 150ms ease-out',
|
|
1557
|
+
}}
|
|
1558
|
+
onMouseEnter={() => {
|
|
1559
|
+
// If a working filter would isolate nothing, route to idle.
|
|
1560
|
+
const idleCount = onlineNodes.length - workingCount;
|
|
1561
|
+
if (workingCount > 0) setHoveredStatus('working');
|
|
1562
|
+
else if (idleCount > 0) setHoveredStatus('idle');
|
|
1563
|
+
}}
|
|
1564
|
+
onMouseLeave={() => setHoveredStatus(prev => prev === 'working' || prev === 'idle' ? null : prev)}
|
|
1565
|
+
// R140: click → /nodes. Mirrors R136 active-links→/messages
|
|
1566
|
+
// idiom. The chip's hover semantics (R79 highlight all
|
|
1567
|
+
// online) keep their meaning — hover for canvas preview,
|
|
1568
|
+
// click for the full list. Pinning idle here would
|
|
1569
|
+
// semantically misrepresent the chip (which means
|
|
1570
|
+
// working+idle), so we navigate instead of pin.
|
|
1571
|
+
onClick={() => {
|
|
1572
|
+
if (onlineNodes.length > 0) router.push('/nodes');
|
|
1573
|
+
}}
|
|
1574
|
+
onKeyDown={(e) => {
|
|
1575
|
+
if (onlineNodes.length === 0) return;
|
|
1576
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1577
|
+
e.preventDefault();
|
|
1578
|
+
router.push('/nodes');
|
|
1579
|
+
}
|
|
1580
|
+
}}
|
|
1581
|
+
>
|
|
1582
|
+
{onlineNodes.length} online
|
|
1583
|
+
</span>
|
|
1584
|
+
</>
|
|
1585
|
+
);
|
|
1586
|
+
})()}
|
|
1587
|
+
{/* Round 31 / Loop: fleet-health pressure bar. The "X working /
|
|
1588
|
+
Y online" chips already carry the raw counts; the bar lets
|
|
1589
|
+
the eye get the working/idle/offline RATIO in one glance
|
|
1590
|
+
without mental math. Stacked 3-segment chip, ~64 px wide. */}
|
|
1591
|
+
{(() => {
|
|
1592
|
+
const w = workingCount;
|
|
1593
|
+
const i = onlineNodes.length - workingCount; // idle = online - working
|
|
1594
|
+
const o = offlineNodes.length;
|
|
1595
|
+
const total = w + i + o;
|
|
1596
|
+
if (total === 0) return null;
|
|
1597
|
+
// Round 60 / Loop: each segment toggles a sticky filter via
|
|
1598
|
+
// `pinnedStatus`. Click the working segment → all non-working
|
|
1599
|
+
// nodes dim; click again → release. Segments share width with
|
|
1600
|
+
// their proportion of `total`, so a 1-node working share is
|
|
1601
|
+
// ~3 px wide on a 64-px bar. We pad the click target with a
|
|
1602
|
+
// negative-margin overlay wrapper to give thin slices a
|
|
1603
|
+
// 14-px minimum hit area without disturbing the rendered
|
|
1604
|
+
// chip width. cursor:pointer + a one-line title hint at the
|
|
1605
|
+
// affordance.
|
|
1606
|
+
const seg = (n: number, color: string, key: 'working' | 'idle' | 'offline', label: string) => {
|
|
1607
|
+
if (n === 0) return null;
|
|
1608
|
+
const isPinned = pinnedStatus === key;
|
|
1609
|
+
// R102: list the aliases that match this segment's bucket
|
|
1610
|
+
// so the title answers WHICH n, not just HOW MANY. Closes
|
|
1611
|
+
// the last "info-density gap" in the chip-row surfaces
|
|
1612
|
+
// (R97 pills / R99 group labels / R101 vendor letters all
|
|
1613
|
+
// already enumerate). Truncates at 8 with "+N more".
|
|
1614
|
+
const matchAliases = key === 'working'
|
|
1615
|
+
? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
|
|
1616
|
+
: key === 'idle'
|
|
1617
|
+
? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
|
|
1618
|
+
: offlineNodes.map(s => s.alias);
|
|
1619
|
+
const previewList = matchAliases.slice(0, 8).join(', ');
|
|
1620
|
+
const suffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
|
|
1621
|
+
const titleAction = isPinned ? 'click to release filter' : 'click to highlight';
|
|
1622
|
+
return (
|
|
1623
|
+
<span
|
|
1624
|
+
key={key}
|
|
1625
|
+
data-pressure-seg={key}
|
|
1626
|
+
data-pressure-seg-aliases={matchAliases.join(',')}
|
|
1627
|
+
role="button"
|
|
1628
|
+
tabIndex={0}
|
|
1629
|
+
aria-pressed={isPinned}
|
|
1630
|
+
className="anet-topo-chip-focus"
|
|
1631
|
+
title={`${n} ${label}\n${previewList}${suffix}\n${titleAction}`}
|
|
1632
|
+
// Round 165 / Loop: smooth width transitions on the
|
|
1633
|
+
// pressure-bar segments. Pre-R165 the widths snapped
|
|
1634
|
+
// instantly when fleet composition shifted (a node
|
|
1635
|
+
// going idle → working would visibly jump the green
|
|
1636
|
+
// segment by a few px). 220ms ease-out makes the bar
|
|
1637
|
+
// visually breathe with state — segment shifts now
|
|
1638
|
+
// glide into place instead of cutting. Pure CSS
|
|
1639
|
+
// transition on width; respects prefers-reduced-
|
|
1640
|
+
// motion via globals.css blanket override that
|
|
1641
|
+
// neutralises transition-duration universally.
|
|
1642
|
+
// boxShadow gets its own transition so pin
|
|
1643
|
+
// state-changes also fade smoothly, not snap.
|
|
1644
|
+
style={{
|
|
1645
|
+
width: `${(n / total) * 100}%`,
|
|
1646
|
+
background: color,
|
|
1647
|
+
height: '100%',
|
|
1648
|
+
cursor: 'pointer',
|
|
1649
|
+
boxShadow: isPinned ? `inset 0 0 0 1px ${color}, inset 0 0 0 2px rgba(255,255,255,0.6)` : undefined,
|
|
1650
|
+
transition: 'width 220ms ease-out, box-shadow 150ms ease-out',
|
|
1651
|
+
}}
|
|
1652
|
+
onClick={(e) => {
|
|
1653
|
+
e.stopPropagation();
|
|
1654
|
+
setPinnedStatus(prev => prev === key ? null : key);
|
|
1655
|
+
}}
|
|
1656
|
+
onKeyDown={(e) => {
|
|
1657
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1658
|
+
e.preventDefault();
|
|
1659
|
+
setPinnedStatus(prev => prev === key ? null : key);
|
|
1660
|
+
}
|
|
1661
|
+
}}
|
|
1662
|
+
// R83: hover preview — segments now match the R55 legend,
|
|
1663
|
+
// R79 working/online chips, and R80 vendor letters in
|
|
1664
|
+
// offering a hover-transient highlight before the click
|
|
1665
|
+
// commits to a pin. Users get to feel what the filter
|
|
1666
|
+
// does before they lock it in. Same activeStatus =
|
|
1667
|
+
// hoveredStatus ?? pinnedStatus formula; releasing the
|
|
1668
|
+
// pointer falls back to the pin if one is set, or to
|
|
1669
|
+
// baseline. Thin (3-px) segments still hit-test fine
|
|
1670
|
+
// because the chip itself is a flex row — span doesn't
|
|
1671
|
+
// need any extra hit padding for hover.
|
|
1672
|
+
onMouseEnter={() => setHoveredStatus(key)}
|
|
1673
|
+
onMouseLeave={() => setHoveredStatus(prev => prev === key ? null : prev)}
|
|
1674
|
+
/>
|
|
1675
|
+
);
|
|
1676
|
+
};
|
|
1677
|
+
// Round 47 / Loop: hidden on mobile — at <640px the chip row
|
|
1678
|
+
// wraps to multiple lines and crowds the topology header;
|
|
1679
|
+
// pressure ratio is best read with the working+online raw
|
|
1680
|
+
// counts (kept visible) anyway.
|
|
1681
|
+
return (
|
|
1682
|
+
<span
|
|
1683
|
+
className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
|
|
1684
|
+
title={`${w} working · ${i} idle · ${o} offline`}
|
|
1685
|
+
data-fleet-pressure
|
|
1686
|
+
>
|
|
1687
|
+
<span className="text-[10px] tracking-wide">pressure</span>
|
|
1688
|
+
<span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
|
|
1689
|
+
{seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
|
|
1690
|
+
{seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
|
|
1691
|
+
{seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
|
|
1692
|
+
</span>
|
|
1693
|
+
</span>
|
|
1694
|
+
);
|
|
1695
|
+
})()}
|
|
1696
|
+
{/* Round 64 / Loop: active-filter pills. When pinnedStatus or
|
|
1697
|
+
pinnedGroup is set, show a small "filter: <key> ×" pill so
|
|
1698
|
+
the user can see the pin from the chip row even if they
|
|
1699
|
+
scrolled the canvas off-screen. × button clears the
|
|
1700
|
+
specific pin (Esc still clears all — both paths are
|
|
1701
|
+
valid). pinnedStatus and pinnedGroup pin independently so
|
|
1702
|
+
both pills may render simultaneously. */}
|
|
1703
|
+
{/* R67: pills enter via anet-fade-in so they appear softly
|
|
1704
|
+
instead of popping. The "filter: " prefix collapses below
|
|
1705
|
+
sm — at narrow viewports the chip row is precious real
|
|
1706
|
+
estate (R47 already hides pressure / vendor / freshness),
|
|
1707
|
+
so dropping the redundant label keeps the working/idle/
|
|
1708
|
+
alpha keys readable without overflow. */}
|
|
1709
|
+
{/* R71: each pill picks up a "· N" match count tail. Tells the
|
|
1710
|
+
user at a glance how many sessions the active filter
|
|
1711
|
+
matches without scanning the canvas. Counts come from the
|
|
1712
|
+
already-computed workingCount + onlineNodes + offlineNodes
|
|
1713
|
+
for status, and groupKeys for group. */}
|
|
1714
|
+
{/* R73: entire pill body is a click-to-clear target — matches
|
|
1715
|
+
the Notion / Linear tag UX (the whole chip releases). The
|
|
1716
|
+
× keeps its dedicated <button> + aria-label for screen
|
|
1717
|
+
readers; the outer span just adds an extra mouse-friendly
|
|
1718
|
+
hit area with a title hint. ×'s onClick stopPropagation
|
|
1719
|
+
so the redundant outer onClick doesn't double-fire (no
|
|
1720
|
+
functional difference since both clear, but cleaner
|
|
1721
|
+
event flow). */}
|
|
1722
|
+
{pinnedStatus && (() => {
|
|
1723
|
+
const matchCount = pinnedStatus === 'working' ? workingCount
|
|
1724
|
+
: pinnedStatus === 'idle' ? (onlineNodes.length - workingCount)
|
|
1725
|
+
: offlineNodes.length;
|
|
1726
|
+
// Round 97 / Loop: the pill says HOW MANY but not WHICH.
|
|
1727
|
+
// Hovering it now shows the matched alias list in the
|
|
1728
|
+
// tooltip — answers "exactly who is this filtering to"
|
|
1729
|
+
// without forcing the user to scan the dim mask on the
|
|
1730
|
+
// canvas. Truncates at 8 names so a 50-node working
|
|
1731
|
+
// bucket doesn't produce a 12-line tooltip; the count
|
|
1732
|
+
// chip already answers "how many overall".
|
|
1733
|
+
const matchAliases = pinnedStatus === 'working'
|
|
1734
|
+
? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
|
|
1735
|
+
: pinnedStatus === 'idle'
|
|
1736
|
+
? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
|
|
1737
|
+
: offlineNodes.map(s => s.alias);
|
|
1738
|
+
const matchPreview = matchAliases.slice(0, 8).join(', ');
|
|
1739
|
+
const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
|
|
1740
|
+
return (
|
|
1741
|
+
<span
|
|
1742
|
+
data-active-filter="status"
|
|
1743
|
+
data-filter-match-count={matchCount}
|
|
1744
|
+
data-filter-match-aliases={matchAliases.join(',')}
|
|
1745
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
|
|
1746
|
+
title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
|
|
1747
|
+
onClick={() => setPinnedStatus(null)}
|
|
1748
|
+
style={{
|
|
1749
|
+
background: pinnedStatus === 'working' ? (isLight ? '#05966914' : '#22c55e1f')
|
|
1750
|
+
: pinnedStatus === 'idle' ? (isLight ? '#0d948814' : '#2dd4bf1f')
|
|
1751
|
+
: (isLight ? '#94a3b814' : '#6b72801f'),
|
|
1752
|
+
color: pinnedStatus === 'working' ? (isLight ? '#047857' : '#86efac')
|
|
1753
|
+
: pinnedStatus === 'idle' ? (isLight ? '#0f766e' : '#5eead4')
|
|
1754
|
+
: (isLight ? '#475569' : '#9ca3af'),
|
|
1755
|
+
borderColor: 'currentColor',
|
|
1756
|
+
cursor: 'pointer',
|
|
1757
|
+
}}
|
|
1758
|
+
>
|
|
1759
|
+
<span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedStatus}<span className="opacity-70"> · {matchCount}</span></span>
|
|
1760
|
+
<button
|
|
1761
|
+
type="button"
|
|
1762
|
+
aria-label={`Clear ${pinnedStatus} filter`}
|
|
1763
|
+
onClick={(e) => { e.stopPropagation(); setPinnedStatus(null); }}
|
|
1764
|
+
className="ml-0.5 leading-none hover:opacity-70"
|
|
1765
|
+
style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
|
1766
|
+
>×</button>
|
|
1767
|
+
</span>
|
|
1768
|
+
);
|
|
1769
|
+
})()}
|
|
1770
|
+
{pinnedGroup && (() => {
|
|
1771
|
+
const matchCount = Object.values(groupKeys).filter(k => k === pinnedGroup).length;
|
|
1772
|
+
// R97: list group members in the tooltip.
|
|
1773
|
+
const matchAliases = Object.entries(groupKeys)
|
|
1774
|
+
.filter(([, key]) => key === pinnedGroup)
|
|
1775
|
+
.map(([alias]) => alias);
|
|
1776
|
+
const matchPreview = matchAliases.slice(0, 8).join(', ');
|
|
1777
|
+
const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
|
|
1778
|
+
return (
|
|
837
1779
|
<span
|
|
838
|
-
|
|
839
|
-
|
|
1780
|
+
data-active-filter="group"
|
|
1781
|
+
data-filter-match-count={matchCount}
|
|
1782
|
+
data-filter-match-aliases={matchAliases.join(',')}
|
|
1783
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
|
|
1784
|
+
title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
|
|
1785
|
+
onClick={() => setPinnedGroup(null)}
|
|
1786
|
+
style={{
|
|
1787
|
+
background: isLight ? '#67e8f914' : '#67e8f91f',
|
|
1788
|
+
color: pal.legendAccent,
|
|
1789
|
+
borderColor: 'currentColor',
|
|
1790
|
+
cursor: 'pointer',
|
|
1791
|
+
}}
|
|
840
1792
|
>
|
|
841
|
-
{
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1793
|
+
<span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedGroup}<span className="opacity-70"> · {matchCount}</span></span>
|
|
1794
|
+
<button
|
|
1795
|
+
type="button"
|
|
1796
|
+
aria-label={`Clear group filter ${pinnedGroup}`}
|
|
1797
|
+
onClick={(e) => { e.stopPropagation(); setPinnedGroup(null); }}
|
|
1798
|
+
className="ml-0.5 leading-none hover:opacity-70"
|
|
1799
|
+
style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
|
1800
|
+
>×</button>
|
|
1801
|
+
</span>
|
|
1802
|
+
);
|
|
1803
|
+
})()}
|
|
1804
|
+
{/* R89: vendor pin gets its own filter pill, matching the R64
|
|
1805
|
+
status + group pattern. R88 added the pin but only the
|
|
1806
|
+
letter itself carried the state; the chip-row had no
|
|
1807
|
+
"filter: A · 2 ×" affordance the other two pins have. The
|
|
1808
|
+
pill colour borrows the vendor's own swatch so each pin
|
|
1809
|
+
still reads in its native hue (A green, O cyan, 书 blue,
|
|
1810
|
+
? slate). Same body-click-clears + × button pattern as
|
|
1811
|
+
R64. */}
|
|
1812
|
+
{pinnedVendor && (() => {
|
|
1813
|
+
const matchEntry = vendorDist.find(v => v.initial === pinnedVendor);
|
|
1814
|
+
const matchCount = matchEntry?.count ?? 0;
|
|
1815
|
+
const vendorColor = matchEntry?.color ?? pal.legendText;
|
|
1816
|
+
// R97: list vendor users in the tooltip. The vendorIdentity
|
|
1817
|
+
// resolver maps model strings to a vendor initial — match
|
|
1818
|
+
// any session whose initial equals the pinned letter (with
|
|
1819
|
+
// unknowns folded to '?').
|
|
1820
|
+
const matchAliases = [...onlineNodes, ...offlineNodes]
|
|
1821
|
+
.filter(s => {
|
|
1822
|
+
const v = vendorForModel(s.model);
|
|
1823
|
+
return (v.id === 'unknown' ? '?' : v.initial) === pinnedVendor;
|
|
1824
|
+
})
|
|
1825
|
+
.map(s => s.alias);
|
|
1826
|
+
const matchPreview = matchAliases.slice(0, 8).join(', ');
|
|
1827
|
+
const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
|
|
1828
|
+
return (
|
|
1829
|
+
<span
|
|
1830
|
+
data-active-filter="vendor"
|
|
1831
|
+
data-filter-match-count={matchCount}
|
|
1832
|
+
data-filter-match-aliases={matchAliases.join(',')}
|
|
1833
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
|
|
1834
|
+
title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
|
|
1835
|
+
onClick={() => setPinnedVendor(null)}
|
|
1836
|
+
style={{
|
|
1837
|
+
background: `${vendorColor}1f`,
|
|
1838
|
+
color: vendorColor,
|
|
1839
|
+
borderColor: 'currentColor',
|
|
1840
|
+
cursor: 'pointer',
|
|
1841
|
+
}}
|
|
1842
|
+
>
|
|
1843
|
+
<span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedVendor}<span className="opacity-70"> · {matchCount}</span></span>
|
|
1844
|
+
<button
|
|
1845
|
+
type="button"
|
|
1846
|
+
aria-label={`Clear vendor filter ${pinnedVendor}`}
|
|
1847
|
+
onClick={(e) => { e.stopPropagation(); setPinnedVendor(null); }}
|
|
1848
|
+
className="ml-0.5 leading-none hover:opacity-70"
|
|
1849
|
+
style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
|
1850
|
+
>×</button>
|
|
1851
|
+
</span>
|
|
1852
|
+
);
|
|
1853
|
+
})()}
|
|
1854
|
+
{/* R119: edge pin filter pill — completes the R64 / R89 pill
|
|
1855
|
+
pattern across all four pin dimensions. R116 added
|
|
1856
|
+
pinnedEdgeKey but the chip row never grew the matching
|
|
1857
|
+
pill, leaving the locked flow visible only on the canvas +
|
|
1858
|
+
recent-signal row tint. This pill shows "filter:
|
|
1859
|
+
alpha→beta · 3" so the locked flow appears in the same
|
|
1860
|
+
row as the other three pin types. Body-click + × button
|
|
1861
|
+
clear, same pattern as R64. */}
|
|
1862
|
+
{pinnedEdgeKey && (() => {
|
|
1863
|
+
const link = flowLinks.find(l => l.key === pinnedEdgeKey);
|
|
1864
|
+
if (!link) return null;
|
|
1865
|
+
// R150 / Loop: extend the hot-lane amber convention from the
|
|
1866
|
+
// canvas badge (R126) / recent-row count (R127) / panel
|
|
1867
|
+
// header (R129) to the R119 edge filter pill — when the
|
|
1868
|
+
// pinned edge has count ≥ 10 the count tspan flips to
|
|
1869
|
+
// amber + 700 weight, and the tooltip grows a "(hot lane
|
|
1870
|
+
// · ≥ 10)" marker. Closes the 4th hot-lane surface,
|
|
1871
|
+
// completing R150's milestone: every place that surfaces
|
|
1872
|
+
// an edge count now uses the same amber-when-hot vocab.
|
|
1873
|
+
const isHot = link.count >= 10;
|
|
1874
|
+
const hotStroke = isLight ? '#d97706' : '#fbbf24';
|
|
1875
|
+
return (
|
|
1876
|
+
<span
|
|
1877
|
+
data-active-filter="edge"
|
|
1878
|
+
data-filter-match-count={link.count}
|
|
1879
|
+
data-filter-match-aliases={`${link.from},${link.to}`}
|
|
1880
|
+
data-active-filter-edge-hot={isHot ? 'true' : 'false'}
|
|
1881
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
|
|
1882
|
+
title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ', hot lane · ≥ 10' : ''}) — click to clear`}
|
|
1883
|
+
onClick={() => setPinnedEdgeKey(null)}
|
|
1884
|
+
style={{
|
|
1885
|
+
background: isLight ? `${pal.flowEdge}14` : `${pal.flowEdge}1f`,
|
|
1886
|
+
color: pal.flowEdge,
|
|
1887
|
+
borderColor: 'currentColor',
|
|
1888
|
+
cursor: 'pointer',
|
|
1889
|
+
}}
|
|
1890
|
+
>
|
|
1891
|
+
<span>
|
|
1892
|
+
<span className="hidden sm:inline" data-filter-prefix>filter: </span>
|
|
1893
|
+
{link.from}→{link.to}
|
|
1894
|
+
{isHot ? (
|
|
1895
|
+
<span
|
|
1896
|
+
className="opacity-90"
|
|
1897
|
+
style={{ color: hotStroke, fontWeight: 700 }}
|
|
1898
|
+
data-active-filter-edge-count-hot
|
|
1899
|
+
>
|
|
1900
|
+
{' · '}{link.count}
|
|
1901
|
+
</span>
|
|
1902
|
+
) : (
|
|
1903
|
+
<span className="opacity-70" data-active-filter-edge-count>
|
|
1904
|
+
{' · '}{link.count}
|
|
1905
|
+
</span>
|
|
1906
|
+
)}
|
|
1907
|
+
</span>
|
|
1908
|
+
<button
|
|
1909
|
+
type="button"
|
|
1910
|
+
aria-label={`Clear edge filter ${link.from} → ${link.to}`}
|
|
1911
|
+
onClick={(e) => { e.stopPropagation(); setPinnedEdgeKey(null); }}
|
|
1912
|
+
className="ml-0.5 leading-none hover:opacity-70"
|
|
1913
|
+
style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
|
1914
|
+
>×</button>
|
|
1915
|
+
</span>
|
|
1916
|
+
);
|
|
1917
|
+
})()}
|
|
1918
|
+
{/* Round 124 / Loop: pin-intersection summary chip. The four
|
|
1919
|
+
filter pills (R64 status, R63 group, R89 vendor, R119
|
|
1920
|
+
edge) each report their own dim's match count in
|
|
1921
|
+
isolation — that answers "what does THIS filter catch"
|
|
1922
|
+
but not "what survives ALL pins". Since the node-opacity
|
|
1923
|
+
chain AND-composes the four pin dimensions, two active
|
|
1924
|
+
pins routinely produce an intersection smaller than
|
|
1925
|
+
either individual count. With three or four pins active
|
|
1926
|
+
the gap widens further. This chip appears ONLY when
|
|
1927
|
+
≥ 2 pin dims are active (a single pin's pill already
|
|
1928
|
+
tells the whole story) and shows the count of nodes
|
|
1929
|
+
that satisfy every active pin simultaneously. Color is
|
|
1930
|
+
deliberately neutral (gray) since the chip represents
|
|
1931
|
+
the AND of mixed-color filters — borrowing any one of
|
|
1932
|
+
the pill hues would mis-signal which dim dominates.
|
|
1933
|
+
Tooltip lists the surviving aliases with the same
|
|
1934
|
+
8-truncate + "+N more" pattern as the individual pill
|
|
1935
|
+
tooltips (R97). No click handler — it's a pure readout;
|
|
1936
|
+
Esc / per-pill × are still the cancel paths. */}
|
|
1937
|
+
{(() => {
|
|
1938
|
+
const pinDimCount =
|
|
1939
|
+
(pinnedStatus ? 1 : 0) +
|
|
1940
|
+
(pinnedGroup ? 1 : 0) +
|
|
1941
|
+
(pinnedVendor ? 1 : 0) +
|
|
1942
|
+
(pinnedEdgeKey ? 1 : 0);
|
|
1943
|
+
if (pinDimCount < 2) return null;
|
|
1944
|
+
const edgeLink = pinnedEdgeKey
|
|
1945
|
+
? flowLinks.find(l => l.key === pinnedEdgeKey)
|
|
1946
|
+
: null;
|
|
1947
|
+
const edgeEndpoints: Set<string> | null = edgeLink
|
|
1948
|
+
? new Set([edgeLink.from, edgeLink.to])
|
|
1949
|
+
: null;
|
|
1950
|
+
const allSessions = [...onlineNodes, ...offlineNodes];
|
|
1951
|
+
const survivors = allSessions.filter(s => {
|
|
1952
|
+
const isOnline = s.status !== 'offline';
|
|
1953
|
+
if (pinnedStatus === 'working' && s.status !== 'working') return false;
|
|
1954
|
+
if (pinnedStatus === 'idle' && !(isOnline && s.status !== 'working')) return false;
|
|
1955
|
+
if (pinnedStatus === 'offline' && isOnline) return false;
|
|
1956
|
+
if (pinnedGroup) {
|
|
1957
|
+
const gk = groupKeys[s.alias] ?? s.alias;
|
|
1958
|
+
if (gk !== pinnedGroup) return false;
|
|
1959
|
+
}
|
|
1960
|
+
if (pinnedVendor) {
|
|
1961
|
+
const v = vendorForModel(s.model);
|
|
1962
|
+
const initial = v.id === 'unknown' ? '?' : v.initial;
|
|
1963
|
+
if (initial !== pinnedVendor) return false;
|
|
1964
|
+
}
|
|
1965
|
+
if (edgeEndpoints && !edgeEndpoints.has(s.alias)) return false;
|
|
1966
|
+
return true;
|
|
1967
|
+
});
|
|
1968
|
+
const matchAliases = survivors.map(s => s.alias);
|
|
1969
|
+
const matchPreview = matchAliases.slice(0, 8).join(', ');
|
|
1970
|
+
const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
|
|
1971
|
+
const isEmpty = matchAliases.length === 0;
|
|
1972
|
+
const tooltip = !isEmpty
|
|
1973
|
+
? `${matchPreview}${matchSuffix} — nodes passing all ${pinDimCount} pinned filters`
|
|
1974
|
+
: `No nodes pass all ${pinDimCount} pinned filters — release one to widen (Esc clears all)`;
|
|
1975
|
+
// R125: when the intersection drops to zero, the chip flips
|
|
1976
|
+
// from neutral gray to a warning amber. Zero-overlap is the
|
|
1977
|
+
// exact case users get confused — canvas dims to 0.28
|
|
1978
|
+
// everywhere, no positive signal explains why. The "· 0"
|
|
1979
|
+
// tail in neutral gray reads as just another number,
|
|
1980
|
+
// indistinguishable from "· 12". Amber + a ⚠ glyph lifts
|
|
1981
|
+
// it to "your filters cancel out" at a glance. Color
|
|
1982
|
+
// choice: amber (#d97706 light, #fbbf24 dark) — same hue
|
|
1983
|
+
// family as warning chips elsewhere in the dashboard,
|
|
1984
|
+
// distinct from any pill color (status green/teal/slate,
|
|
1985
|
+
// group cyan, vendor varies, edge cyan, neutral gray for
|
|
1986
|
+
// non-empty intersection) so the empty state stands out.
|
|
1987
|
+
const emptyColor = isLight ? '#d97706' : '#fbbf24';
|
|
1988
|
+
return (
|
|
1989
|
+
<span
|
|
1990
|
+
data-pin-intersection
|
|
1991
|
+
data-pin-dim-count={pinDimCount}
|
|
1992
|
+
data-pin-intersection-count={matchAliases.length}
|
|
1993
|
+
data-pin-intersection-empty={isEmpty ? 'true' : 'false'}
|
|
1994
|
+
data-pin-intersection-aliases={matchAliases.join(',')}
|
|
1995
|
+
className="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
|
|
1996
|
+
title={tooltip}
|
|
1997
|
+
style={{
|
|
1998
|
+
background: isEmpty
|
|
1999
|
+
? (isLight ? '#d97706' + '14' : '#fbbf24' + '1f')
|
|
2000
|
+
: (isLight ? '#94a3b814' : '#94a3b81f'),
|
|
2001
|
+
color: isEmpty
|
|
2002
|
+
? emptyColor
|
|
2003
|
+
: (isLight ? '#475569' : '#9ca3af'),
|
|
2004
|
+
borderColor: 'currentColor',
|
|
2005
|
+
}}
|
|
2006
|
+
>
|
|
2007
|
+
<span>
|
|
2008
|
+
<span className="hidden sm:inline" data-pin-intersection-prefix>match: </span>
|
|
2009
|
+
{pinDimCount} pins<span className="opacity-70"> · {matchAliases.length}</span>
|
|
2010
|
+
{isEmpty && <span className="ml-1" aria-hidden>⚠</span>}
|
|
845
2011
|
</span>
|
|
846
|
-
|
|
2012
|
+
</span>
|
|
2013
|
+
);
|
|
2014
|
+
})()}
|
|
2015
|
+
{vendorDist.length > 1 && (
|
|
2016
|
+
<span
|
|
2017
|
+
className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
|
|
2018
|
+
title="Hover to highlight; click to pin"
|
|
2019
|
+
>
|
|
2020
|
+
{vendorDist.map(v => {
|
|
2021
|
+
const isPinned = pinnedVendor === v.initial;
|
|
2022
|
+
// R88: click toggles a sticky filter the same way R60
|
|
2023
|
+
// pressure-bar segments toggle pinnedStatus. Visual
|
|
2024
|
+
// mirror = inset boxShadow using the vendor's own
|
|
2025
|
+
// colour, so each pinned letter sings in its own hue
|
|
2026
|
+
// (Anthropic green / OpenAI cyan / 书 blue / ?).
|
|
2027
|
+
// R101: tooltip lists the aliases that use this vendor —
|
|
2028
|
+
// completes the info-density triple started by R97 pills,
|
|
2029
|
+
// R98 node titles, R99 group-label titles. Anywhere the
|
|
2030
|
+
// UI shows "A:3" should hover-explain which 3.
|
|
2031
|
+
const aliases = [...onlineNodes, ...offlineNodes]
|
|
2032
|
+
.filter(s => {
|
|
2033
|
+
const vid = vendorForModel(s.model);
|
|
2034
|
+
return (vid.id === 'unknown' ? '?' : vid.initial) === v.initial;
|
|
2035
|
+
})
|
|
2036
|
+
.map(s => s.alias);
|
|
2037
|
+
const preview = aliases.slice(0, 8).join(', ');
|
|
2038
|
+
const suffix = aliases.length > 8 ? ` + ${aliases.length - 8} more` : '';
|
|
2039
|
+
const tooltip = isPinned
|
|
2040
|
+
? `${preview}${suffix} — click again or Esc to clear`
|
|
2041
|
+
: `${preview}${suffix} — click to pin`;
|
|
2042
|
+
return (
|
|
2043
|
+
<span
|
|
2044
|
+
key={v.initial}
|
|
2045
|
+
role="button"
|
|
2046
|
+
tabIndex={0}
|
|
2047
|
+
aria-pressed={isPinned}
|
|
2048
|
+
className="inline-flex items-baseline gap-0.5 px-1 rounded anet-topo-chip-focus"
|
|
2049
|
+
data-vendor-letter={v.initial}
|
|
2050
|
+
data-vendor-pinned={isPinned ? 'true' : 'false'}
|
|
2051
|
+
data-vendor-aliases={aliases.join(',')}
|
|
2052
|
+
title={tooltip}
|
|
2053
|
+
// R180: smooth-pin-mirror family — see working chip above.
|
|
2054
|
+
style={{
|
|
2055
|
+
cursor: 'pointer',
|
|
2056
|
+
boxShadow: isPinned
|
|
2057
|
+
? `inset 0 0 0 1px ${v.color}, inset 0 0 0 2px rgba(255,255,255,0.45)`
|
|
2058
|
+
: undefined,
|
|
2059
|
+
transition: 'box-shadow 150ms ease-out',
|
|
2060
|
+
}}
|
|
2061
|
+
onMouseEnter={() => setHoveredVendor(v.initial)}
|
|
2062
|
+
onMouseLeave={() => setHoveredVendor(prev => prev === v.initial ? null : prev)}
|
|
2063
|
+
onClick={() => setPinnedVendor(prev => prev === v.initial ? null : v.initial)}
|
|
2064
|
+
onKeyDown={(e) => {
|
|
2065
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2066
|
+
e.preventDefault();
|
|
2067
|
+
setPinnedVendor(prev => prev === v.initial ? null : v.initial);
|
|
2068
|
+
}
|
|
2069
|
+
}}
|
|
2070
|
+
>
|
|
2071
|
+
<span style={{ color: v.color }}>{v.initial}</span>
|
|
2072
|
+
<span className="text-gray-500">:{v.count}</span>
|
|
2073
|
+
</span>
|
|
2074
|
+
);
|
|
2075
|
+
})}
|
|
847
2076
|
</span>
|
|
848
2077
|
)}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
2078
|
+
{/* Round 42 / Loop: extend active-links chip with the timestamp
|
|
2079
|
+
of the most-recent flow event. Tells the operator at a glance
|
|
2080
|
+
whether the topology is currently humming (last 30s) or has
|
|
2081
|
+
been quiet for a while — the visual flow particles and
|
|
2082
|
+
edge brightness only show that there IS traffic, not when
|
|
2083
|
+
the last one was. Reuses Round 38's relativeAgo. */}
|
|
2084
|
+
{(() => {
|
|
2085
|
+
const recent = flowLinks.reduce<number | null>((acc, l) => {
|
|
2086
|
+
if (!l.last_at) return acc;
|
|
2087
|
+
const t = parseHubTime(l.last_at);
|
|
2088
|
+
if (t === null) return acc;
|
|
2089
|
+
return acc === null || t > acc ? t : acc;
|
|
2090
|
+
}, null);
|
|
2091
|
+
const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
|
|
2092
|
+
// R114: tooltip lists the actual flows. Closes the
|
|
2093
|
+
// info-density sweep on the last chip-row hover surface
|
|
2094
|
+
// (R97-R113 covered everything else). Format:
|
|
2095
|
+
// "alpha→beta (3), gamma→delta (1) — hover brightens all"
|
|
2096
|
+
// Truncates at 6 flows with "+N more" so a busy fleet
|
|
2097
|
+
// doesn't paint a tall tooltip; the recent-signal panel
|
|
2098
|
+
// already shows the top 3 in detail.
|
|
2099
|
+
const flowList = flowLinks
|
|
2100
|
+
.slice(0, 6)
|
|
2101
|
+
.map(l => `${l.from}→${l.to} (${l.count})`)
|
|
2102
|
+
.join(', ');
|
|
2103
|
+
const flowSuffix = flowLinks.length > 6 ? ` + ${flowLinks.length - 6} more` : '';
|
|
2104
|
+
// R136: the chip already had cursor:pointer when flowLinks
|
|
2105
|
+
// > 0 (line 1877) — but no onClick was wired. The cursor
|
|
2106
|
+
// lied; users got the affordance signal with no follow-
|
|
2107
|
+
// through. Wire it to /messages, mirroring R133's footer-
|
|
2108
|
+
// nav idiom. Hover (R77) keeps its semantic "preview all
|
|
2109
|
+
// flows on canvas"; click is the action "open the full
|
|
2110
|
+
// list". Two distinct gestures, both meaningful. The
|
|
2111
|
+
// tooltip grows a "click to open" tail when interactive.
|
|
2112
|
+
// Drop the chip out of click territory entirely when
|
|
2113
|
+
// flowLinks is empty — no flows = no list to open.
|
|
2114
|
+
const isInteractive = flowLinks.length > 0;
|
|
2115
|
+
const tooltip = !isInteractive
|
|
2116
|
+
? undefined
|
|
2117
|
+
: `${flowList}${flowSuffix} — hover brightens all · click to open /messages`;
|
|
2118
|
+
return (
|
|
2119
|
+
// Round 193 / Loop: the chip itself adopts a subtle cyan
|
|
2120
|
+
// tint on its own hover, mirroring the cyan flowEdge
|
|
2121
|
+
// highlight it fires on the canvas. Pre-R193 the gesture
|
|
2122
|
+
// was visually asymmetric:
|
|
2123
|
+
// hover this chip → canvas edges brighten (cyan)
|
|
2124
|
+
// chip itself → stays gray (silent)
|
|
2125
|
+
// The *cause element* (chip) gave no response while the
|
|
2126
|
+
// *effect element* (canvas edges) painted loud. Same
|
|
2127
|
+
// pin-mirror logic R165 / R180 use on the four other
|
|
2128
|
+
// chip-row surfaces — the chip and the canvas edge it
|
|
2129
|
+
// pins should speak the same color vocabulary. Tailwind
|
|
2130
|
+
// :hover variant cyan-500/10 bg + cyan-500/30 border +
|
|
2131
|
+
// cyan-200 text matches the same palette R178/R163 use
|
|
2132
|
+
// for the active chrome buttons. transition-colors
|
|
2133
|
+
// duration-200 blends the swap smoothly. Hover variant
|
|
2134
|
+
// only attaches when isInteractive — a chip showing
|
|
2135
|
+
// "0 active links" has no list to open and should
|
|
2136
|
+
// stay gray. data-active-links-clickable already
|
|
2137
|
+
// exposes that gate to tests.
|
|
2138
|
+
<span
|
|
2139
|
+
className={`hidden sm:inline px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-colors duration-200 ${
|
|
2140
|
+
isInteractive
|
|
2141
|
+
? 'bg-gray-500/10 text-gray-400 border-gray-500/20 hover:bg-cyan-500/10 hover:text-cyan-200 hover:border-cyan-500/30'
|
|
2142
|
+
: 'bg-gray-500/10 text-gray-400 border-gray-500/20'
|
|
2143
|
+
}`}
|
|
2144
|
+
data-active-links-chip
|
|
2145
|
+
data-active-links-flow-count={flowLinks.length}
|
|
2146
|
+
data-active-links-clickable={isInteractive ? 'true' : 'false'}
|
|
2147
|
+
title={tooltip}
|
|
2148
|
+
role={isInteractive ? 'link' : undefined}
|
|
2149
|
+
tabIndex={isInteractive ? 0 : undefined}
|
|
2150
|
+
style={{ cursor: isInteractive ? 'pointer' : undefined }}
|
|
2151
|
+
onMouseEnter={() => { if (isInteractive) setHoveredActiveLinks(true); }}
|
|
2152
|
+
onMouseLeave={() => setHoveredActiveLinks(false)}
|
|
2153
|
+
onClick={() => { if (isInteractive) router.push('/messages'); }}
|
|
2154
|
+
onKeyDown={(e) => {
|
|
2155
|
+
if (!isInteractive) return;
|
|
2156
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2157
|
+
e.preventDefault();
|
|
2158
|
+
router.push('/messages');
|
|
2159
|
+
}
|
|
2160
|
+
}}
|
|
2161
|
+
>
|
|
2162
|
+
{flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
|
|
2163
|
+
{rel ? (() => {
|
|
2164
|
+
// Round 161 / Loop: extend R160's recency-pip
|
|
2165
|
+
// vocabulary up one scope — from per-flow row to
|
|
2166
|
+
// fleet-aggregate chip. The chip already shows
|
|
2167
|
+
// recency in text ("last 5s ago"); the " · "
|
|
2168
|
+
// separator bullet was dead gray. Color the
|
|
2169
|
+
// bullet by freshness using the same alpha
|
|
2170
|
+
// ramp R160 uses on recent-signal rows:
|
|
2171
|
+
// ageSec ≤ 30 → 1.0 (fully fresh)
|
|
2172
|
+
// 30-300s → smooth decay 1.0 → 0.25
|
|
2173
|
+
// > 300s → 0.25 (stale floor)
|
|
2174
|
+
// Cyan bullet pulse when fresh + gray text tail
|
|
2175
|
+
// = "is the network firing right now" readable
|
|
2176
|
+
// at a glance, without parsing the timestamp
|
|
2177
|
+
// numerals. Same vocabulary the canvas uses
|
|
2178
|
+
// (R10 edge fade) and the recent-signal panel
|
|
2179
|
+
// uses (R160 row pip) — three nested scopes
|
|
2180
|
+
// now share one freshness ladder.
|
|
2181
|
+
const ageSec = recent !== null
|
|
2182
|
+
? Math.max(0, (Date.now() - recent) / 1000)
|
|
2183
|
+
: 999;
|
|
2184
|
+
const alpha = ageSec <= 30
|
|
2185
|
+
? 1
|
|
2186
|
+
: ageSec <= 300
|
|
2187
|
+
? 1 - ((ageSec - 30) / 270) * 0.75
|
|
2188
|
+
: 0.25;
|
|
2189
|
+
// Cyan dark / teal light to match palette legendAccent.
|
|
2190
|
+
const dotColor = isLight
|
|
2191
|
+
? `rgba(13, 148, 136, ${alpha.toFixed(2)})`
|
|
2192
|
+
: `rgba(34, 211, 238, ${alpha.toFixed(2)})`;
|
|
2193
|
+
return (
|
|
2194
|
+
<span className="text-gray-500">
|
|
2195
|
+
<span
|
|
2196
|
+
data-active-links-freshness-dot
|
|
2197
|
+
data-active-links-freshness-alpha={alpha.toFixed(2)}
|
|
2198
|
+
style={{
|
|
2199
|
+
color: dotColor,
|
|
2200
|
+
fontWeight: alpha > 0.7 ? 700 : 400,
|
|
2201
|
+
transition: 'color 200ms ease-out',
|
|
2202
|
+
}}
|
|
2203
|
+
>{' · '}</span>
|
|
2204
|
+
last {rel}
|
|
2205
|
+
</span>
|
|
2206
|
+
);
|
|
2207
|
+
})() : null}
|
|
2208
|
+
</span>
|
|
2209
|
+
);
|
|
2210
|
+
})()}
|
|
2211
|
+
<FreshnessChip sessions={sessions} />
|
|
852
2212
|
</div>
|
|
853
2213
|
</div>
|
|
854
2214
|
|
|
@@ -859,16 +2219,55 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
859
2219
|
>
|
|
860
2220
|
<div className={`absolute inset-x-0 top-0 h-px bg-gradient-to-r ${pal.topRailGradient}`} />
|
|
861
2221
|
|
|
2222
|
+
{/* Round 158 / Loop: give the canvas SVG itself an accessible
|
|
2223
|
+
name + role description. R151-R157 added a11y to every
|
|
2224
|
+
interactive surface inside the canvas (nodes, group labels,
|
|
2225
|
+
badges, rows, minimap, chrome buttons) — but the canvas
|
|
2226
|
+
container itself was a nameless 1000×680 box. A screen
|
|
2227
|
+
reader that hit the SVG before tab-diving into its
|
|
2228
|
+
children heard nothing identifying it as "the topology".
|
|
2229
|
+
aria-roledescription gives it a meaningful announcement;
|
|
2230
|
+
aria-label provides a live snapshot of the network
|
|
2231
|
+
(online / working / active links / offline) plus the two
|
|
2232
|
+
canvas-scope gestures (Tab + double-click). Default SVG
|
|
2233
|
+
role is graphics-document so children remain navigable —
|
|
2234
|
+
we deliberately don't override role with "img" which
|
|
2235
|
+
would flatten the SVG to a single opaque image. */}
|
|
862
2236
|
<svg
|
|
863
2237
|
ref={svgRef}
|
|
864
2238
|
viewBox="0 0 1000 680"
|
|
865
2239
|
className="w-full h-auto block"
|
|
866
2240
|
preserveAspectRatio="xMidYMid meet"
|
|
2241
|
+
aria-roledescription="agent network topology"
|
|
2242
|
+
aria-label={(() => {
|
|
2243
|
+
const online = onlineNodes.length;
|
|
2244
|
+
const working = workingCount;
|
|
2245
|
+
const offline = offlineNodes.length;
|
|
2246
|
+
const flows = flowLinks.length;
|
|
2247
|
+
const parts: string[] = [];
|
|
2248
|
+
parts.push(`${online} agent${online === 1 ? '' : 's'} online`);
|
|
2249
|
+
if (working > 0) parts.push(`${working} working`);
|
|
2250
|
+
if (offline > 0) parts.push(`${offline} offline`);
|
|
2251
|
+
parts.push(`${flows} active link${flows === 1 ? '' : 's'}`);
|
|
2252
|
+
return `Agent network topology — ${parts.join(' · ')}. Tab to navigate nodes, double-click canvas to reset view.`;
|
|
2253
|
+
})()}
|
|
2254
|
+
data-topo-canvas-aria
|
|
867
2255
|
onPointerDown={onPointerDown}
|
|
868
2256
|
onPointerMove={onPointerMove}
|
|
869
2257
|
onPointerUp={onPointerUp}
|
|
870
2258
|
onPointerLeave={onPointerUp}
|
|
871
|
-
|
|
2259
|
+
// Round 41 / Loop: the reset-button title (R22) and the Help
|
|
2260
|
+
// overlay (R25) both advertise "double-click the canvas to
|
|
2261
|
+
// reset", but the handler was never actually wired. The text
|
|
2262
|
+
// was lying. Wire it here, guarded so dbl-clicking a node
|
|
2263
|
+
// (which would also trigger the SVG-level handler via event
|
|
2264
|
+
// bubbling) doesn't unexpectedly reset the view on the user.
|
|
2265
|
+
onDoubleClick={(e) => {
|
|
2266
|
+
const t = e.target as Element | null;
|
|
2267
|
+
if (t?.closest('g[data-node]')) return;
|
|
2268
|
+
resetView();
|
|
2269
|
+
}}
|
|
2270
|
+
style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
|
|
872
2271
|
>
|
|
873
2272
|
<defs>
|
|
874
2273
|
<linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
|
|
@@ -890,9 +2289,56 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
890
2289
|
</feMerge>
|
|
891
2290
|
</filter>
|
|
892
2291
|
)}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
2292
|
+
{/* R142 / Loop: drop-shadow filter for group-box hover-lift.
|
|
2293
|
+
Mirrors R135's panel hover-elevation idiom but applied
|
|
2294
|
+
at the per-group canvas level. R68 already gives a
|
|
2295
|
+
hovered/pinned group box a solid accent stroke; R142
|
|
2296
|
+
adds a soft outward shadow on top so the box visually
|
|
2297
|
+
"rises off the canvas" when selected — same visual
|
|
2298
|
+
vocabulary as the panels + the Overview KPI cards
|
|
2299
|
+
(R18). Theme-aware flood: darker shadow on cyber so
|
|
2300
|
+
it reads above the dark canvas, lighter for light
|
|
2301
|
+
theme. Bounding box of the group box is unchanged —
|
|
2302
|
+
filters only affect paint area, not bbox geometry, so
|
|
2303
|
+
the overlap-test invariant is preserved. */}
|
|
2304
|
+
<filter id="topo-groupbox-lift" x="-10%" y="-10%" width="120%" height="120%">
|
|
2305
|
+
<feDropShadow
|
|
2306
|
+
dx="0" dy="3" stdDeviation="4"
|
|
2307
|
+
floodColor={isLight ? '#0f172a' : '#000000'}
|
|
2308
|
+
floodOpacity={isLight ? 0.18 : 0.55}
|
|
2309
|
+
/>
|
|
2310
|
+
</filter>
|
|
2311
|
+
{/* Round 16 / Loop: 3-tier flow-link arrow markers.
|
|
2312
|
+
The single marker had `markerUnits` defaulting to
|
|
2313
|
+
`strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
|
|
2314
|
+
unit arrowheads — visually dominant, the head outweighed
|
|
2315
|
+
the line. Switching to `userSpaceOnUse` decouples arrow
|
|
2316
|
+
size from stroke; binning by count gives a clearer
|
|
2317
|
+
hierarchy than continuous linear scaling:
|
|
2318
|
+
s (count 1-2) → 12 user units
|
|
2319
|
+
m (count 3-4) → 16 user units (alias for `topo-arrow`)
|
|
2320
|
+
l (count 5+) → 22 user units
|
|
2321
|
+
`topo-arrow` stays bound to the medium tier so the legend
|
|
2322
|
+
swatch (line ~1500) renders without change. */}
|
|
2323
|
+
{[
|
|
2324
|
+
{ id: 'topo-arrow-s', size: 12 },
|
|
2325
|
+
{ id: 'topo-arrow', size: 16 },
|
|
2326
|
+
{ id: 'topo-arrow-l', size: 22 },
|
|
2327
|
+
].map(m => (
|
|
2328
|
+
<marker
|
|
2329
|
+
key={m.id}
|
|
2330
|
+
id={m.id}
|
|
2331
|
+
viewBox="0 0 10 10"
|
|
2332
|
+
refX="8"
|
|
2333
|
+
refY="5"
|
|
2334
|
+
markerWidth={m.size}
|
|
2335
|
+
markerHeight={m.size}
|
|
2336
|
+
markerUnits="userSpaceOnUse"
|
|
2337
|
+
orient="auto-start-reverse"
|
|
2338
|
+
>
|
|
2339
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
|
|
2340
|
+
</marker>
|
|
2341
|
+
))}
|
|
896
2342
|
{/* Round 45: radial radar sweep gradient — bright at center
|
|
897
2343
|
(where it meets the hub) fading to leading edge. */}
|
|
898
2344
|
<radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
|
|
@@ -906,19 +2352,54 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
906
2352
|
<rect width="1000" height="680" fill="url(#topo-panel)" />
|
|
907
2353
|
|
|
908
2354
|
{/* Round 103 (issue #81): everything inside this <g> zooms + pans
|
|
909
|
-
together. transform order = translate then scale.
|
|
910
|
-
|
|
2355
|
+
together. transform order = translate then scale.
|
|
2356
|
+
Round 168 / Loop: smoothView arms a one-shot transition on
|
|
2357
|
+
the transform attribute, active only when resetView/fitView
|
|
2358
|
+
fires. Pan (R103 pointer drag) and wheel zoom never set the
|
|
2359
|
+
flag, so they stay snappy with no lag. Pressing `0`, `f`,
|
|
2360
|
+
clicking the hub (R52), or chrome reset/fit buttons triggers
|
|
2361
|
+
a 300ms ease-out glide instead of a jolt. Respects prefers-
|
|
2362
|
+
reduced-motion via the R29 globals.css blanket override that
|
|
2363
|
+
neutralises transition-duration universally. */}
|
|
2364
|
+
<g
|
|
2365
|
+
transform={`translate(${view.x} ${view.y}) scale(${view.zoom})`}
|
|
2366
|
+
data-topo-viewport
|
|
2367
|
+
data-topo-viewport-smooth={smoothView ? 'true' : 'false'}
|
|
2368
|
+
data-topo-viewport-layout-switching={layoutSwitching ? 'true' : 'false'}
|
|
2369
|
+
data-topo-viewport-nodesize-switching={nodeSizeSwitching ? 'true' : 'false'}
|
|
2370
|
+
style={{
|
|
2371
|
+
// R170 / Loop: opacity always carries a transition so the
|
|
2372
|
+
// layout-switch crossfade fires cleanly. R168 smoothView
|
|
2373
|
+
// transition on transform is added only when armed (pan
|
|
2374
|
+
// and wheel zoom MUST stay snappy). The two arming flags
|
|
2375
|
+
// compose without conflict — different visual axes.
|
|
2376
|
+
// R171: nodeSizeSwitching shares the same opacity-dim
|
|
2377
|
+
// pathway as layoutSwitching — clicking S/M/L in the
|
|
2378
|
+
// chrome triggers the same soft-blink masking. ORing
|
|
2379
|
+
// both flags keeps the opacity expression simple while
|
|
2380
|
+
// the two distinct data-* attributes let tests
|
|
2381
|
+
// disambiguate which gesture armed the crossfade.
|
|
2382
|
+
transition: smoothView
|
|
2383
|
+
? 'opacity 250ms ease-out, transform 300ms ease-out'
|
|
2384
|
+
: 'opacity 250ms ease-out',
|
|
2385
|
+
opacity: (layoutSwitching || nodeSizeSwitching) ? 0.45 : 1,
|
|
2386
|
+
}}
|
|
2387
|
+
>
|
|
911
2388
|
{/* Issue #87: radar/ring ambiance renders only in ring layout —
|
|
912
2389
|
grid mode drops it so the concentric rings don't sit behind a
|
|
913
2390
|
rectangular grid. */}
|
|
914
2391
|
{layout === 'ring' && (<>
|
|
915
|
-
|
|
2392
|
+
{/* R52: radar bg is pure decoration — drop its pointer events so
|
|
2393
|
+
the hub <g> under it (and any future inner-disk affordances)
|
|
2394
|
+
can receive clicks. Previously the r=330 disc intercepted
|
|
2395
|
+
the hub click outright. */}
|
|
2396
|
+
<circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
|
|
916
2397
|
|
|
917
2398
|
{/* Round 45: subtle star field — 24 deterministic dots scattered
|
|
918
2399
|
across the canvas give the radar bg some depth. Skipped on
|
|
919
2400
|
light theme so the white surface stays clean. */}
|
|
920
2401
|
{!isLight && (
|
|
921
|
-
<g opacity="0.5">
|
|
2402
|
+
<g opacity="0.5" style={{ pointerEvents: 'none' }}>
|
|
922
2403
|
{Array.from({ length: 28 }).map((_, i) => {
|
|
923
2404
|
// Deterministic pseudo-random scatter so positions are
|
|
924
2405
|
// stable between renders (no JS hydration mismatch).
|
|
@@ -936,50 +2417,191 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
936
2417
|
scan without being noisy. Inline transform-origin on the
|
|
937
2418
|
<g> wrapper ensures Chrome / Firefox rotate around (cx,cy)
|
|
938
2419
|
instead of the SVG viewBox corner. */}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
2420
|
+
{(() => {
|
|
2421
|
+
// R146: radar sweep rotation buckets on workingCount, joining
|
|
2422
|
+
// R84 hub breath / R131 outer orbit / R132 group march /
|
|
2423
|
+
// R145 idle spokes as the 5th and final layer in the busyness-
|
|
2424
|
+
// driven motion family. 8 / 6 / 4 / 3 seconds — same 0 / 1-2 /
|
|
2425
|
+
// 3-5 / 6+ thresholds R84 uses. "Busier fleet = more frequent
|
|
2426
|
+
// scans" feels semantically right for a radar idiom. R45
|
|
2427
|
+
// baseline 6s sits at bucket 1 so the calm/busy spread bracks
|
|
2428
|
+
// around the historical default.
|
|
2429
|
+
const busy = workingCount === 0 ? 0
|
|
2430
|
+
: workingCount <= 2 ? 1
|
|
2431
|
+
: workingCount <= 5 ? 2
|
|
2432
|
+
: 3;
|
|
2433
|
+
const sweepDur = [8, 6, 4, 3][busy];
|
|
2434
|
+
return (
|
|
2435
|
+
<g
|
|
2436
|
+
style={{
|
|
2437
|
+
transformOrigin: `${cx}px ${cy}px`,
|
|
2438
|
+
transformBox: 'view-box',
|
|
2439
|
+
pointerEvents: 'none',
|
|
2440
|
+
// CSS var consumed by `.anet-topo-sweep` (line 848 of
|
|
2441
|
+
// globals.css). React's CSSProperties type doesn't model
|
|
2442
|
+
// custom properties → cast through Record<string, string>.
|
|
2443
|
+
...({ ['--sweep-dur']: `${sweepDur}s` } as Record<string, string>),
|
|
2444
|
+
} as React.CSSProperties}
|
|
2445
|
+
className="anet-topo-sweep"
|
|
2446
|
+
opacity={isLight ? 0.7 : 1}
|
|
2447
|
+
data-topo-sweep-bucket={busy}
|
|
2448
|
+
data-topo-sweep-dur={sweepDur}
|
|
2449
|
+
>
|
|
2450
|
+
<path
|
|
2451
|
+
d={`M ${cx} ${cy} L ${cx + 330} ${cy} A 330 330 0 0 0 ${cx + 330 * Math.cos(-Math.PI / 4.5)} ${cy + 330 * Math.sin(-Math.PI / 4.5)} Z`}
|
|
2452
|
+
fill="url(#topo-sweep)"
|
|
2453
|
+
/>
|
|
2454
|
+
</g>
|
|
2455
|
+
);
|
|
2456
|
+
})()}
|
|
952
2457
|
|
|
953
|
-
{/* radar rings
|
|
2458
|
+
{/* radar rings — pure decoration at fixed radii, independent of
|
|
2459
|
+
node positions so the radar aesthetic is preserved across tier
|
|
2460
|
+
changes. */}
|
|
954
2461
|
{[90, 170, 250, 330].map(radius => (
|
|
955
2462
|
<circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
|
|
956
2463
|
))}
|
|
957
2464
|
|
|
2465
|
+
{/* Round 54 / Loop: tier-radius guide rings. The radar rings above
|
|
2466
|
+
are decorative and don't match the actual tier radii nodes sit
|
|
2467
|
+
on (single 220 / dual 175,260 / triple 145,215,285). Drawing
|
|
2468
|
+
a faint dashed ring at each ACTIVE tier radius lets the eye
|
|
2469
|
+
anchor "this is the inner / outer ring" without inferring from
|
|
2470
|
+
node spacing. Picked based on online node count so only the
|
|
2471
|
+
tiers currently in use draw — empty tiers stay quiet. pointer-
|
|
2472
|
+
events:none so they never intercept hub or node clicks. The
|
|
2473
|
+
0.7 stroke + dashed pattern reads as guide, not feature. */}
|
|
2474
|
+
{(() => {
|
|
2475
|
+
const tierRadii = onlineNodes.length > onlineTripleThreshold
|
|
2476
|
+
? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
|
|
2477
|
+
: onlineNodes.length > onlineTierThreshold
|
|
2478
|
+
? [onlineInnerRadius, onlineOuterRadius]
|
|
2479
|
+
: onlineNodes.length > 0
|
|
2480
|
+
? [onlineRadius]
|
|
2481
|
+
: [];
|
|
2482
|
+
// Round 92 / Loop: tier-ring occupancy. R54 drew the guide
|
|
2483
|
+
// rings at fixed opacity regardless of how many nodes lived
|
|
2484
|
+
// on each tier. With pinned filters dimming most nodes to
|
|
2485
|
+
// 0.28, the ring at a deserted tier looked identical to a
|
|
2486
|
+
// crowded one — wasting a free piece of canvas. Count
|
|
2487
|
+
// online nodes whose hub-distance falls within ±15 px of
|
|
2488
|
+
// each ring (15 px = half the inter-tier gap, so each
|
|
2489
|
+
// node is assigned to exactly one ring). Empty tier → skip
|
|
2490
|
+
// entirely. Crowded tier → stronger opacity, says "look
|
|
2491
|
+
// here". Buckets so the ladder feels intentional, not
|
|
2492
|
+
// jittery as one node migrates between tiers.
|
|
2493
|
+
const occupancyOf = (r: number) => onlineNodes.reduce((acc, s) => {
|
|
2494
|
+
const p = nodePositions[s.alias];
|
|
2495
|
+
if (!p) return acc;
|
|
2496
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
2497
|
+
return Math.abs(d - r) < 15 ? acc + 1 : acc;
|
|
2498
|
+
}, 0);
|
|
2499
|
+
// Round 93 / Loop: when any pin is active, tint the tier
|
|
2500
|
+
// rings to the legend accent so the spatial guide visually
|
|
2501
|
+
// shares the canvas's "filtered mode" colour. The chip
|
|
2502
|
+
// row already says WHICH filter is on (pills + letter
|
|
2503
|
+
// mirror); rings answering "we're filtered" reinforces
|
|
2504
|
+
// the state when the eye is on the canvas, not the chip
|
|
2505
|
+
// row. Composes cleanly with R92 occupancy — same opacity
|
|
2506
|
+
// bucket logic; just the stroke colour swaps.
|
|
2507
|
+
const anyPin = !!(pinnedStatus || pinnedGroup || pinnedVendor);
|
|
2508
|
+
const tierStroke = anyPin ? pal.legendAccent : pal.ringStroke;
|
|
2509
|
+
return tierRadii.map((r, tierIdx) => {
|
|
2510
|
+
const n = occupancyOf(r);
|
|
2511
|
+
if (n === 0) return null;
|
|
2512
|
+
const bucket = n <= 2 ? 0 : n <= 6 ? 1 : 2;
|
|
2513
|
+
const opLight = [0.24, 0.36, 0.50][bucket];
|
|
2514
|
+
const opDark = [0.32, 0.46, 0.62][bucket];
|
|
2515
|
+
// Round 174 / Loop: tier guide rings fade-in alongside
|
|
2516
|
+
// the R9/R72/R172/R173 first-paint wave. Ring layout's
|
|
2517
|
+
// structural scaffolding (R54 dashed concentric guides)
|
|
2518
|
+
// was the last instant-pop element after R173 closed
|
|
2519
|
+
// group boxes in grid. Same vocabulary — .anet-fade-in
|
|
2520
|
+
// mount-once CSS animation + per-ring stagger 60ms ×
|
|
2521
|
+
// tierIdx (cap 8). Tier rings are at most 3 (single /
|
|
2522
|
+
// dual / triple), so the visible range is 0-120ms.
|
|
2523
|
+
// Inner ring leads outward — emanates from the hub.
|
|
2524
|
+
// transition list grows `opacity 250ms ease-out` so the
|
|
2525
|
+
// post-animation snap from animation's end state (1) to
|
|
2526
|
+
// the bucket opacity (0.24-0.62) eases instead of cuts.
|
|
2527
|
+
// Same pattern node fade-in uses (R9 + transition-opacity
|
|
2528
|
+
// from R3 className). data-tier-fade-delay exposes the
|
|
2529
|
+
// computed delay for test probing.
|
|
2530
|
+
const fadeDelay = Math.min(tierIdx, 8) * 60;
|
|
2531
|
+
return (
|
|
2532
|
+
<circle
|
|
2533
|
+
key={`tier-${r}`}
|
|
2534
|
+
cx={cx} cy={cy} r={r}
|
|
2535
|
+
fill="none"
|
|
2536
|
+
stroke={tierStroke}
|
|
2537
|
+
strokeWidth="0.7"
|
|
2538
|
+
strokeDasharray="2 8"
|
|
2539
|
+
opacity={isLight ? opLight : opDark}
|
|
2540
|
+
className="anet-fade-in"
|
|
2541
|
+
style={{
|
|
2542
|
+
pointerEvents: 'none',
|
|
2543
|
+
transition: 'stroke 200ms ease-out, opacity 250ms ease-out',
|
|
2544
|
+
animationDelay: `${fadeDelay}ms`,
|
|
2545
|
+
}}
|
|
2546
|
+
data-tier-ring={r}
|
|
2547
|
+
data-tier-occupancy={n}
|
|
2548
|
+
data-tier-bucket={bucket}
|
|
2549
|
+
data-tier-tinted={anyPin ? 'true' : 'false'}
|
|
2550
|
+
data-tier-fade-delay={fadeDelay}
|
|
2551
|
+
/>
|
|
2552
|
+
);
|
|
2553
|
+
});
|
|
2554
|
+
})()}
|
|
2555
|
+
|
|
958
2556
|
{/* Round 50: 4 small particles slowly orbiting the outer ring
|
|
959
2557
|
(r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
|
|
960
2558
|
of the cycle) so they're evenly spaced. 16s per revolution is
|
|
961
2559
|
slow enough to feel ambient, not noisy. Skipped on light theme
|
|
962
|
-
so the white surface stays clean.
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
2560
|
+
so the white surface stays clean.
|
|
2561
|
+
|
|
2562
|
+
R131 / Loop: orbit period now buckets on workingCount,
|
|
2563
|
+
mirroring R84's hub-busyness breath cadence. An idle
|
|
2564
|
+
fleet keeps the original 16s "calm sweep"; as work
|
|
2565
|
+
accumulates the orbit subtly accelerates (capped at
|
|
2566
|
+
10s so it never feels frantic). Two-layer motion
|
|
2567
|
+
coordination now: R84 breathes the hub, R131 spins
|
|
2568
|
+
the outer ring — both reading the same underlying
|
|
2569
|
+
"is the network busy" signal, both visible
|
|
2570
|
+
simultaneously without competing. Same bucket
|
|
2571
|
+
thresholds (0 / 1-2 / 3-5 / 6+) the R84 block uses
|
|
2572
|
+
at line ~2702 so the two cadences stay in sync if
|
|
2573
|
+
a future refactor shifts buckets. */}
|
|
2574
|
+
{!isLight && (() => {
|
|
2575
|
+
const busy = workingCount === 0 ? 0
|
|
2576
|
+
: workingCount <= 2 ? 1
|
|
2577
|
+
: workingCount <= 5 ? 2
|
|
2578
|
+
: 3;
|
|
2579
|
+
const dur = [16, 14, 12, 10][busy];
|
|
2580
|
+
return [0, 0.25, 0.5, 0.75].map((phase, i) => (
|
|
2581
|
+
<g
|
|
2582
|
+
key={`orbit-${i}`}
|
|
2583
|
+
data-topo-orbit-bucket={busy}
|
|
2584
|
+
data-topo-orbit-dur={dur}
|
|
971
2585
|
>
|
|
972
|
-
<
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
2586
|
+
<circle
|
|
2587
|
+
cx={cx + 330} cy={cy}
|
|
2588
|
+
r={i === 0 ? 2.8 : 2.2}
|
|
2589
|
+
fill="#22d3ee"
|
|
2590
|
+
opacity={0.9}
|
|
2591
|
+
filter="url(#topo-glow)"
|
|
2592
|
+
>
|
|
2593
|
+
<animateTransform
|
|
2594
|
+
attributeName="transform"
|
|
2595
|
+
type="rotate"
|
|
2596
|
+
from={`${phase * 360} ${cx} ${cy}`}
|
|
2597
|
+
to={`${phase * 360 + 360} ${cx} ${cy}`}
|
|
2598
|
+
dur={`${dur}s`}
|
|
2599
|
+
repeatCount="indefinite"
|
|
2600
|
+
/>
|
|
2601
|
+
</circle>
|
|
2602
|
+
</g>
|
|
2603
|
+
));
|
|
2604
|
+
})()}
|
|
983
2605
|
|
|
984
2606
|
{[0, 30, 60, 90, 120, 150].map(angle => (
|
|
985
2607
|
<line
|
|
@@ -998,65 +2620,302 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
998
2620
|
{/* hub links — round 46: idle spokes now have animated
|
|
999
2621
|
stroke-dashoffset so dashes flow outward from the hub
|
|
1000
2622
|
("command relay" feel). Active spokes carrying live message
|
|
1001
|
-
flow stay as solid bright strokes.
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
2623
|
+
flow stay as solid bright strokes.
|
|
2624
|
+
R145 / Loop: idle-spoke animation cadence buckets on
|
|
2625
|
+
workingCount, mirroring R84 hub-breath / R131 outer-
|
|
2626
|
+
ring orbit / R132 groupbox-march coordination. Same
|
|
2627
|
+
0 / 1-2 / 3-5 / 6+ thresholds. Idle ↔ idle network
|
|
2628
|
+
has a slow "command relay" feel; as work accumulates
|
|
2629
|
+
the dashes accelerate outward, completing the 4th
|
|
2630
|
+
motion layer in the busyness-driven family:
|
|
2631
|
+
R84 hub breath (centre)
|
|
2632
|
+
R131 outer ring orbit (periphery)
|
|
2633
|
+
R132 groupbox march (per-team, grid layout)
|
|
2634
|
+
R145 idle-spoke flow (ring layout, hub→nodes)
|
|
2635
|
+
4 surfaces, 1 signal. Same cadence ladder 2.8/2.4/2.0/
|
|
2636
|
+
1.6s — 1.75× range from calm to busy, capped so the
|
|
2637
|
+
network never feels frantic. */}
|
|
2638
|
+
{layout === 'ring' && (() => {
|
|
2639
|
+
const busy = workingCount === 0 ? 0
|
|
2640
|
+
: workingCount <= 2 ? 1
|
|
2641
|
+
: workingCount <= 5 ? 2
|
|
2642
|
+
: 3;
|
|
2643
|
+
const spokeDur = [2.8, 2.4, 2.0, 1.6][busy];
|
|
2644
|
+
return onlineNodes.map((session, idx) => {
|
|
2645
|
+
const pos = nodePositions[session.alias];
|
|
2646
|
+
if (!pos) return null;
|
|
2647
|
+
const path = curvePath({ x: cx, y: cy }, pos, 0);
|
|
2648
|
+
const isActiveSpoke = activeAliases.has(session.alias);
|
|
1007
2649
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
2650
|
+
return (
|
|
2651
|
+
<path
|
|
2652
|
+
key={`hub-${session.alias}`}
|
|
2653
|
+
d={path}
|
|
2654
|
+
fill="none"
|
|
2655
|
+
stroke={isActiveSpoke ? pal.spokeStroke.active : pal.spokeStroke.idle}
|
|
2656
|
+
strokeWidth={isActiveSpoke ? 2 : 1}
|
|
2657
|
+
strokeDasharray={isActiveSpoke ? 'none' : '6 14'}
|
|
2658
|
+
opacity={isActiveSpoke ? 0.7 : 0.45}
|
|
2659
|
+
className={isActiveSpoke ? undefined : 'anet-topo-spoke-flow'}
|
|
2660
|
+
data-topo-spoke-bucket={isActiveSpoke ? undefined : busy}
|
|
2661
|
+
data-topo-spoke-dur={isActiveSpoke ? undefined : spokeDur}
|
|
2662
|
+
style={isActiveSpoke ? undefined : ({
|
|
2663
|
+
animationDelay: `${-(idx * 0.25)}s`,
|
|
2664
|
+
// CSS var consumed by `.anet-topo-spoke-flow`
|
|
2665
|
+
// (line 859 of globals.css). React's CSSProperties
|
|
2666
|
+
// type doesn't model custom properties → cast
|
|
2667
|
+
// through Record<string, string>.
|
|
2668
|
+
...({ ['--spoke-dur']: `${spokeDur}s` } as Record<string, string>),
|
|
2669
|
+
} as React.CSSProperties)}
|
|
2670
|
+
/>
|
|
2671
|
+
);
|
|
2672
|
+
});
|
|
2673
|
+
})()}
|
|
1022
2674
|
|
|
1023
2675
|
{/* #111: prefix-group boundary boxes (Vincent 4722). Grid layout
|
|
1024
2676
|
only — groupBoxes is empty in ring mode. Rendered behind the
|
|
1025
2677
|
flow links + nodes; pointer-events off so they never intercept
|
|
1026
2678
|
a node click. Restrained dashed container + group-name label. */}
|
|
1027
|
-
{groupBoxes.map(box =>
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
2679
|
+
{groupBoxes.map((box, boxIdx) => {
|
|
2680
|
+
const isHovered = activeGroup === box.key;
|
|
2681
|
+
// R68: distinguish "locked by click" from "currently hovered".
|
|
2682
|
+
// R63 made pinned and hovered identical (both hit isHovered
|
|
2683
|
+
// via activeGroup). A user with one team pinned should see at
|
|
2684
|
+
// a glance which is the locked one even while their cursor
|
|
2685
|
+
// sweeps elsewhere. isPinned reads pinnedGroup directly
|
|
2686
|
+
// (NOT activeGroup) so the visual is specific to the sticky
|
|
2687
|
+
// state — transient hover keeps the R63 isHovered styling.
|
|
2688
|
+
const isPinned = pinnedGroup === box.key;
|
|
2689
|
+
// Round 18 / Loop: group-box hover linkage. The Round 8 fade
|
|
2690
|
+
// already dropped OUT-of-focus groups to 0.28, but the IN-focus
|
|
2691
|
+
// group sat at its baseline appearance — no positive emphasis.
|
|
2692
|
+
// Hovering now upgrades the box to an "accent" treatment:
|
|
2693
|
+
// solid stroke (not dashed), thicker, accent-coloured; brighter
|
|
2694
|
+
// text and slightly stronger fill. Label and box read as one
|
|
2695
|
+
// selected unit. Geometry unchanged → overlap test untouched.
|
|
2696
|
+
// R132: per-group marching-ants duration computed once,
|
|
2697
|
+
// reused on the data attribute + the inline custom property.
|
|
2698
|
+
const w = box.statuses.working;
|
|
2699
|
+
const marchDur = w >= 6 ? 8 : w >= 4 ? 10 : w >= 2 ? 12 : 14;
|
|
2700
|
+
return (
|
|
2701
|
+
<g
|
|
2702
|
+
key={`grp-${box.key}`}
|
|
2703
|
+
data-group={box.key}
|
|
2704
|
+
// Round 173 / Loop: group boxes pick up the first-paint
|
|
2705
|
+
// fade-in wave alongside R9 staggered nodes (0-540ms)
|
|
2706
|
+
// and R172 staggered edges (280-980ms). Pre-R173 the
|
|
2707
|
+
// structural box frames appeared instantly while the
|
|
2708
|
+
// nodes inside eased in — the reveal felt like
|
|
2709
|
+
// 'frame slams down, nodes drift in'. The .anet-fade-in
|
|
2710
|
+
// CSS animation (R3 origin, 0.15s ease-out, mount-once)
|
|
2711
|
+
// plays alongside the node R9 stagger; each box offset
|
|
2712
|
+
// by boxIdx × 60ms (cap at 8 indices so a fleet with
|
|
2713
|
+
// many groups still finishes within ~500ms) so boxes
|
|
2714
|
+
// appear like a soft sweep across the grid rather than
|
|
2715
|
+
// a single pop. animation-fill-mode default 'none' →
|
|
2716
|
+
// post-animation control reverts to the inline opacity
|
|
2717
|
+
// style below (1 / 0.28 based on filter pin state).
|
|
2718
|
+
// data-group-fade-delay exposes the computed delay for
|
|
2719
|
+
// test probes.
|
|
2720
|
+
className="transition-opacity anet-fade-in"
|
|
2721
|
+
data-group-fade-delay={Math.min(boxIdx, 8) * 60}
|
|
2722
|
+
// R63: drop the blanket pointerEvents:'none' that
|
|
2723
|
+
// previously sat here. Chrome's SVG impl doesn't let a
|
|
2724
|
+
// child override a parent's `none` even though the spec
|
|
2725
|
+
// says it should — moving the property onto just the
|
|
2726
|
+
// rect (where it's needed so nodes underneath stay
|
|
2727
|
+
// clickable) lets the label text receive its own click.
|
|
2728
|
+
style={{
|
|
2729
|
+
opacity: !activeGroup || isHovered ? 1 : 0.28,
|
|
2730
|
+
animationDelay: `${Math.min(boxIdx, 8) * 60}ms`,
|
|
2731
|
+
}}
|
|
1055
2732
|
>
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
2733
|
+
<rect
|
|
2734
|
+
x={box.x}
|
|
2735
|
+
y={box.y}
|
|
2736
|
+
width={box.w}
|
|
2737
|
+
height={box.h}
|
|
2738
|
+
rx="14"
|
|
2739
|
+
fill={isLight ? '#0f172a' : '#a5b4fc'}
|
|
2740
|
+
// R68: 3-tier opacity + stroke ladder.
|
|
2741
|
+
// pinned → fill 0.08 / 0.13, stroke 3 px (locked)
|
|
2742
|
+
// hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
|
|
2743
|
+
// idle → fill 0.025 / 0.045, stroke 1.5 px dashed
|
|
2744
|
+
fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
|
|
2745
|
+
: isHovered ? (isLight ? 0.05 : 0.09)
|
|
2746
|
+
: (isLight ? 0.025 : 0.045)}
|
|
2747
|
+
stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
|
|
2748
|
+
strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
|
|
2749
|
+
strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
|
|
2750
|
+
data-group-box-pinned={isPinned ? 'true' : 'false'}
|
|
2751
|
+
// R85: ambient "marching ants" drift on the perimeter
|
|
2752
|
+
// when this group has at least one working member, and
|
|
2753
|
+
// neither pin nor hover is active (those treatments
|
|
2754
|
+
// already shout for attention via solid stroke). 12s
|
|
2755
|
+
// cycle reads as ambient — the eye parses "live work
|
|
2756
|
+
// here" without registering the box as animating.
|
|
2757
|
+
// R132: per-group ant rate buckets on box.statuses.working
|
|
2758
|
+
// — the same coupling-to-busyness idiom R84 uses for the
|
|
2759
|
+
// hub and R131 uses for the outer-ring orbit, applied at
|
|
2760
|
+
// the GROUP scale. A team with one working member ambles;
|
|
2761
|
+
// a team with five working members visibly accelerates.
|
|
2762
|
+
// Bucket boundaries (1 / 2-3 / 4-5 / 6+) chosen to land
|
|
2763
|
+
// on the same 14/12/10/8 cadence ladder so the three
|
|
2764
|
+
// motion layers (hub / ring / group) keep a coherent
|
|
2765
|
+
// tempo grammar. Default 12s when working=0 doesn't
|
|
2766
|
+
// matter — the className is only applied when working>0.
|
|
2767
|
+
data-group-box-live={!isPinned && !isHovered && box.statuses.working > 0 ? 'true' : 'false'}
|
|
2768
|
+
data-group-box-march-dur={marchDur}
|
|
2769
|
+
data-group-box-lifted={(isPinned || isHovered) ? 'true' : 'false'}
|
|
2770
|
+
className={!isPinned && !isHovered && box.statuses.working > 0 ? 'anet-topo-groupbox-live' : undefined}
|
|
2771
|
+
// R142: drop-shadow filter when pinned or hovered. Box
|
|
2772
|
+
// visually "rises off the canvas" — same vocabulary
|
|
2773
|
+
// R18 KPI cards + R135 overlay panels use. Idle group
|
|
2774
|
+
// boxes carry no filter (purely flat dashed outline)
|
|
2775
|
+
// so the unstyled canvas stays uncluttered. Filter
|
|
2776
|
+
// affects paint area only, not the geometric bbox
|
|
2777
|
+
// the overlap-test reads, so zero-overlap invariant
|
|
2778
|
+
// is preserved.
|
|
2779
|
+
filter={(isPinned || isHovered) ? 'url(#topo-groupbox-lift)' : undefined}
|
|
2780
|
+
style={{
|
|
2781
|
+
transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out, filter 200ms ease-out',
|
|
2782
|
+
pointerEvents: 'none',
|
|
2783
|
+
// CSS var consumed by `.anet-topo-groupbox-live`
|
|
2784
|
+
// (line 877 of globals.css). React's CSSProperties
|
|
2785
|
+
// type doesn't model custom properties, so cast
|
|
2786
|
+
// through Record<string, string>.
|
|
2787
|
+
...({['--march-dur']: `${marchDur}s`} as Record<string, string>),
|
|
2788
|
+
}}
|
|
2789
|
+
/>
|
|
2790
|
+
{/* R63: wrap label in a clickable <g> with an invisible
|
|
2791
|
+
rect hitbox. The text alone wasn't getting hit-tested
|
|
2792
|
+
reliably — the SVG-wide topo-panel <rect> intercepts
|
|
2793
|
+
at the label's screen position when the label sits at
|
|
2794
|
+
a high viewBox-y (it lands below where the compositor
|
|
2795
|
+
expects the text to paint on top, same gotcha as the
|
|
2796
|
+
recent-signal panel rows in R56). Hitbox rect + the
|
|
2797
|
+
parent <g> taking the click + onPointerDown stop-
|
|
2798
|
+
propagation match the R55/R56/R61 pattern. */}
|
|
2799
|
+
<g
|
|
2800
|
+
role="button"
|
|
2801
|
+
tabIndex={0}
|
|
2802
|
+
aria-pressed={pinnedGroup === box.key}
|
|
2803
|
+
data-group-label-hit={box.key}
|
|
2804
|
+
className="anet-topo-svg-focus"
|
|
2805
|
+
style={{ pointerEvents: 'all', cursor: 'pointer' }}
|
|
2806
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
2807
|
+
onClick={() => setPinnedGroup(prev => prev === box.key ? null : box.key)}
|
|
2808
|
+
// R86: hover the label → transient group focus. Releases
|
|
2809
|
+
// on leave; activeGroup = hoveredGroup ?? pinnedGroup
|
|
2810
|
+
// formula already handles transient-over-pin so a
|
|
2811
|
+
// user can spot-compare teams without losing their
|
|
2812
|
+
// pinned one. Closes the same R83-style hover/click
|
|
2813
|
+
// gap (segments) — now group labels carry it too.
|
|
2814
|
+
onPointerEnter={() => setHoveredGroupLabel(box.key)}
|
|
2815
|
+
onPointerLeave={() => setHoveredGroupLabel(prev => prev === box.key ? null : prev)}
|
|
2816
|
+
// R152: a11y completeness — R63 added role + tabIndex +
|
|
2817
|
+
// aria-pressed but never wired onKeyDown, so the focused
|
|
2818
|
+
// group label was tab-reachable but Enter/Space was a
|
|
2819
|
+
// no-op. Closes the last keyboard gap among the
|
|
2820
|
+
// role="button" surfaces. Other group-pin trigger paths
|
|
2821
|
+
// (R69 palette, R74 cmdk, R86 hover, dispatchEvent) are
|
|
2822
|
+
// unchanged. Matches the onKeyDown idiom from R116 /
|
|
2823
|
+
// R139 / R140 / R151 (Enter & Space → same setter as
|
|
2824
|
+
// onClick, preventDefault on Space to stop SVG scroll).
|
|
2825
|
+
onKeyDown={(e) => {
|
|
2826
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2827
|
+
e.preventDefault();
|
|
2828
|
+
setPinnedGroup(prev => prev === box.key ? null : box.key);
|
|
2829
|
+
}
|
|
2830
|
+
}}
|
|
2831
|
+
>
|
|
2832
|
+
{/* R99: SVG <title> tooltip listing group members +
|
|
2833
|
+
status breakdown. Same info-density spirit as
|
|
2834
|
+
R97 pill tooltips + R98 node tooltips — anywhere
|
|
2835
|
+
a UI element says "alpha · 3" should hover-
|
|
2836
|
+
explain WHICH 3. Truncates at 8 aliases with a
|
|
2837
|
+
"+N more" suffix so a 20-member band doesn't
|
|
2838
|
+
paint a 22-line tooltip. */}
|
|
2839
|
+
{(() => {
|
|
2840
|
+
const members = Object.entries(groupKeys)
|
|
2841
|
+
.filter(([, key]) => key === box.key)
|
|
2842
|
+
.map(([alias]) => alias);
|
|
2843
|
+
const memberPreview = members.slice(0, 8).join(', ');
|
|
2844
|
+
const suffix = members.length > 8 ? ` + ${members.length - 8} more` : '';
|
|
2845
|
+
const statusSummary = [
|
|
2846
|
+
box.statuses.working > 0 ? `${box.statuses.working} working` : null,
|
|
2847
|
+
box.statuses.idle > 0 ? `${box.statuses.idle} idle` : null,
|
|
2848
|
+
box.statuses.offline > 0 ? `${box.statuses.offline} offline` : null,
|
|
2849
|
+
].filter(Boolean).join(' · ');
|
|
2850
|
+
return (
|
|
2851
|
+
<title>{[
|
|
2852
|
+
`${box.key} (${members.length} member${members.length === 1 ? '' : 's'})`,
|
|
2853
|
+
statusSummary || null,
|
|
2854
|
+
`${memberPreview}${suffix}`,
|
|
2855
|
+
pinnedGroup === box.key ? 'click to release pin' : 'click to pin this group',
|
|
2856
|
+
].filter(Boolean).join('\n')}</title>
|
|
2857
|
+
);
|
|
2858
|
+
})()}
|
|
2859
|
+
<rect
|
|
2860
|
+
x={box.x + 6}
|
|
2861
|
+
y={box.y + 2}
|
|
2862
|
+
width={Math.min(box.w - 12, 240)}
|
|
2863
|
+
height={20}
|
|
2864
|
+
rx="4"
|
|
2865
|
+
/* R107 / Loop: list-item tint extends to the SVG
|
|
2866
|
+
group labels — same idiom R104 added to recent-
|
|
2867
|
+
signal rows and R105 to the legend rows. The
|
|
2868
|
+
tint colour is pal.legendAccent (cyan) since
|
|
2869
|
+
groups don't carry an inherent swatch the way
|
|
2870
|
+
legend rows do; this matches R68's group-box
|
|
2871
|
+
isPinned/isHovered accent stroke for consistency.
|
|
2872
|
+
hover < pinned opacity so locked vs preview is
|
|
2873
|
+
discriminable at a glance. */
|
|
2874
|
+
fill={pinnedGroup === box.key || hoveredGroupLabel === box.key ? pal.legendAccent : 'transparent'}
|
|
2875
|
+
opacity={pinnedGroup === box.key ? (isLight ? 0.16 : 0.20)
|
|
2876
|
+
: hoveredGroupLabel === box.key ? (isLight ? 0.09 : 0.13)
|
|
2877
|
+
: 1}
|
|
2878
|
+
data-group-label-tinted={pinnedGroup === box.key ? 'pinned' : hoveredGroupLabel === box.key ? 'hover' : 'none'}
|
|
2879
|
+
style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
|
|
2880
|
+
/>
|
|
2881
|
+
<text
|
|
2882
|
+
x={box.x + 12}
|
|
2883
|
+
y={box.y + 14}
|
|
2884
|
+
fill={isHovered ? pal.legendHeadline : pal.legendText}
|
|
2885
|
+
fontSize="13"
|
|
2886
|
+
fontFamily="monospace"
|
|
2887
|
+
fontWeight="700"
|
|
2888
|
+
style={{ transition: 'fill 200ms ease-out' }}
|
|
2889
|
+
data-group-label={box.key}
|
|
2890
|
+
>
|
|
2891
|
+
{box.key}
|
|
2892
|
+
{/* Round 19 / Loop: member-count chip. Inline tspan stays
|
|
2893
|
+
in the single <text> bbox the overlap test reads, so
|
|
2894
|
+
the node↔label guard still catches if the chip ever
|
|
2895
|
+
pushes the label far enough right to clip a node.
|
|
2896
|
+
Smaller + lighter weight reads as metadata, not name. */}
|
|
2897
|
+
<tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
|
|
2898
|
+
{/* Round 58 / Loop: status mix pip strip. Compact text-
|
|
2899
|
+
based chips (e.g. "2w 1i") so the strip stays inside
|
|
2900
|
+
the same <text> bbox the overlap-test reads — keeps
|
|
2901
|
+
the R27 label↔label and R19 node↔label guards intact.
|
|
2902
|
+
Each tier is colour-coded against the legend swatches
|
|
2903
|
+
and only renders when count > 0, so a healthy all-
|
|
2904
|
+
working group reads simply " · 2w". */}
|
|
2905
|
+
{box.statuses.working > 0 && (
|
|
2906
|
+
<tspan dx="8" fill={isLight ? '#059669' : '#22c55e'} fontSize="11" fontWeight="600">{box.statuses.working}w</tspan>
|
|
2907
|
+
)}
|
|
2908
|
+
{box.statuses.idle > 0 && (
|
|
2909
|
+
<tspan dx="4" fill={isLight ? '#0d9488' : '#2dd4bf'} fontSize="11" fontWeight="600">{box.statuses.idle}i</tspan>
|
|
2910
|
+
)}
|
|
2911
|
+
{box.statuses.offline > 0 && (
|
|
2912
|
+
<tspan dx="4" fill={isLight ? '#94a3b8' : '#6b7280'} fontSize="11" fontWeight="600">{box.statuses.offline}o</tspan>
|
|
2913
|
+
)}
|
|
2914
|
+
</text>
|
|
2915
|
+
</g>
|
|
2916
|
+
</g>
|
|
2917
|
+
);
|
|
2918
|
+
})}
|
|
1060
2919
|
|
|
1061
2920
|
{/* directed message flows */}
|
|
1062
2921
|
{flowLinks.map((link, index) => {
|
|
@@ -1082,18 +2941,147 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1082
2941
|
// — accuracy is within the poll interval, plenty.
|
|
1083
2942
|
const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
|
|
1084
2943
|
const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
|
|
2944
|
+
// Round 16 arrow-tier binning — keep `topo-arrow` as the
|
|
2945
|
+
// medium tier id so the legend swatch picks it up unchanged.
|
|
2946
|
+
const arrowId = link.count <= 2 ? 'topo-arrow-s'
|
|
2947
|
+
: link.count <= 4 ? 'topo-arrow'
|
|
2948
|
+
: 'topo-arrow-l';
|
|
1085
2949
|
|
|
2950
|
+
// Round 39 / Loop: edge hover tooltip — surface the same
|
|
2951
|
+
// last_at + count info the freshness fade and arrow tier
|
|
2952
|
+
// already encode visually, in plain text. The stroke is the
|
|
2953
|
+
// hover target; SVG `<title>` honours newlines on every
|
|
2954
|
+
// browser the dashboard targets.
|
|
2955
|
+
const lastAt = relativeAgo(link.last_at);
|
|
2956
|
+
const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
|
|
2957
|
+
// Round 40 / Loop: edges follow node hover — when an alias is
|
|
2958
|
+
// hovered, every edge touching it brightens, the rest fade.
|
|
2959
|
+
// Pairs with the Round 8 group-focus fade on nodes: hover a
|
|
2960
|
+
// node to find "who is this agent talking to" at a glance.
|
|
2961
|
+
// No hover → multiplier is 1.0 (current behaviour preserved).
|
|
2962
|
+
// Round 50 / Loop: edge-on-self priority. When the user hovers
|
|
2963
|
+
// a flow edge directly (R48 widened the hitbox so this is now
|
|
2964
|
+
// a precise gesture), THAT edge gets the strongest boost (2.0)
|
|
2965
|
+
// and every other edge dims to 0.35. Node-hover (R40) keeps
|
|
2966
|
+
// its own ladder. Edge-hover and node-hover are mutually
|
|
2967
|
+
// exclusive in practice — the cursor is over one or the
|
|
2968
|
+
// other — but the order below makes edge-hover win if both
|
|
2969
|
+
// ever read truthy at the same React tick.
|
|
2970
|
+
// Round 53 / Loop: in-group edges follow team focus. R40
|
|
2971
|
+
// brightened only edges touching the exact hovered alias —
|
|
2972
|
+
// but with R8/R18 prefix-clustering, a user hovering one
|
|
2973
|
+
// member is asking about the team. So when BOTH endpoints of
|
|
2974
|
+
// an edge share the hoveredGroup (and neither is the exact
|
|
2975
|
+
// hovered alias — that gets the stronger 1.7×), the edge
|
|
2976
|
+
// boosts to 1.3×. Edges leaving the team (one endpoint in,
|
|
2977
|
+
// one out) still dim to 0.35× per R40 since they read as
|
|
2978
|
+
// background to the focus. Singletons fall through unchanged
|
|
2979
|
+
// (their group key is the alias itself, so bothInHoveredGroup
|
|
2980
|
+
// is impossible for a non-self-edge).
|
|
2981
|
+
// R116: composes hover ?? pin — a pinned edge stays "hot" after the cursor leaves.
|
|
2982
|
+
const isHoveredEdge = activeEdgeKey === link.key;
|
|
2983
|
+
const fromGroup = groupKeys[link.from] ?? link.from;
|
|
2984
|
+
const toGroup = groupKeys[link.to] ?? link.to;
|
|
2985
|
+
const bothInHoveredGroup = !!activeGroup && fromGroup === activeGroup && toGroup === activeGroup;
|
|
2986
|
+
// R63: also gate "no filter" on activeGroup so pinnedGroup
|
|
2987
|
+
// alone activates the in-group 1.3× boost + non-group dim.
|
|
2988
|
+
// When neither a node nor a group is in focus, mul is 1.
|
|
2989
|
+
// R77: when the user hovers the "N active links" chip, the
|
|
2990
|
+
// baseline 1× becomes 1.5× — every flow brightens at once.
|
|
2991
|
+
// Sits inside the no-other-hover branch so it doesn't fight
|
|
2992
|
+
// the edge-hover (2.0×) or node-hover (1.7×) priorities.
|
|
2993
|
+
const edgeOpacityMul = isHoveredEdge
|
|
2994
|
+
? 2.0
|
|
2995
|
+
: activeEdgeKey
|
|
2996
|
+
? 0.35
|
|
2997
|
+
: !hoveredAlias && !activeGroup
|
|
2998
|
+
? (hoveredActiveLinks ? 1.5 : 1)
|
|
2999
|
+
: (link.from === hoveredAlias || link.to === hoveredAlias)
|
|
3000
|
+
? 1.7
|
|
3001
|
+
: bothInHoveredGroup
|
|
3002
|
+
? 1.3
|
|
3003
|
+
: 0.35;
|
|
3004
|
+
// Round 50: the hovered edge also visibly thickens so the eye
|
|
3005
|
+
// tracks it even at low message counts (width was 3 → 4.5 on
|
|
3006
|
+
// hover). 1.4× is enough to read as "lifted" without
|
|
3007
|
+
// breaching the 16-px hitbox bound.
|
|
3008
|
+
const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
|
|
1086
3009
|
return (
|
|
1087
|
-
<g
|
|
3010
|
+
<g
|
|
3011
|
+
key={link.key}
|
|
3012
|
+
// Round 172 / Loop: edges fade-in alongside the R9
|
|
3013
|
+
// staggered node reveal so the canvas first-paint
|
|
3014
|
+
// reads as one coordinated wave instead of "edges
|
|
3015
|
+
// pop, nodes ease in". The .anet-fade-in CSS
|
|
3016
|
+
// animation (0.15s ease-out, runs once on mount,
|
|
3017
|
+
// R3 origin) plays only on first render — React
|
|
3018
|
+
// preserves the <g> via the link.key on subsequent
|
|
3019
|
+
// re-renders so the animation doesn't replay when
|
|
3020
|
+
// flowLinks recomputes (every 5s SSE poll).
|
|
3021
|
+
// Animation-delay offsets each edge by 280ms +
|
|
3022
|
+
// 35ms × index so edges start fading in AFTER
|
|
3023
|
+
// most nodes have appeared (node R9 stagger caps
|
|
3024
|
+
// at ~600ms; ring layout R72 emanates 0→540ms).
|
|
3025
|
+
// Cap at 20 indices so a busy fleet with 50
|
|
3026
|
+
// flowLinks still finishes within ~1s. Respects
|
|
3027
|
+
// prefers-reduced-motion via R29 globals.css
|
|
3028
|
+
// blanket that neutralises animation-duration.
|
|
3029
|
+
className="anet-fade-in"
|
|
3030
|
+
style={{
|
|
3031
|
+
animationDelay: `${280 + Math.min(index, 20) * 35}ms`,
|
|
3032
|
+
}}
|
|
3033
|
+
data-edge-group={link.key}
|
|
3034
|
+
>
|
|
3035
|
+
{/* Round 48 / Loop: invisible hover hitbox — visible flow
|
|
3036
|
+
path is 3-7 px wide and damn hard to hover precisely.
|
|
3037
|
+
Stack a transparent 16-px-wide stroke behind it so the
|
|
3038
|
+
cursor only needs to be ~8 px from the line for the
|
|
3039
|
+
tooltip to fire. Native <title> moves here; the
|
|
3040
|
+
visible path no longer needs it. pointer-events on
|
|
3041
|
+
the visible path drop to "none" since the hitbox
|
|
3042
|
+
owns the hover surface. */}
|
|
3043
|
+
<path
|
|
3044
|
+
d={path}
|
|
3045
|
+
fill="none"
|
|
3046
|
+
stroke="transparent"
|
|
3047
|
+
strokeWidth={Math.max(width + 10, 16)}
|
|
3048
|
+
style={{ pointerEvents: 'stroke' }}
|
|
3049
|
+
data-edge-hitbox
|
|
3050
|
+
onMouseEnter={() => setHoveredEdgeKey(link.key)}
|
|
3051
|
+
onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
|
|
3052
|
+
>
|
|
3053
|
+
<title>{tooltip}</title>
|
|
3054
|
+
</path>
|
|
3055
|
+
{/* Round 166 / Loop: stroke-width transition pairs
|
|
3056
|
+
with R164 edge badge r-lift. Pre-R166 the
|
|
3057
|
+
visible flow path's hover thickening (R50:
|
|
3058
|
+
renderWidth = isHoveredEdge ? width * 1.4 :
|
|
3059
|
+
width) snapped instantly even though opacity
|
|
3060
|
+
transitioned smoothly. Edge hover now lifts
|
|
3061
|
+
the line AND the badge in coordinated 300ms
|
|
3062
|
+
ease-out motion. Drop the Tailwind transition
|
|
3063
|
+
class for inline style so both opacity and
|
|
3064
|
+
stroke-width pick up the same timing without
|
|
3065
|
+
arbitrary-property class compilation risk.
|
|
3066
|
+
data-edge-visible exposes the path for test
|
|
3067
|
+
probes (the R48 hitbox sibling already has
|
|
3068
|
+
data-edge-hitbox). Respects prefers-reduced-
|
|
3069
|
+
motion via the R29 globals.css blanket
|
|
3070
|
+
override that neutralises transition-duration
|
|
3071
|
+
universally. */}
|
|
1088
3072
|
<path
|
|
1089
3073
|
d={path}
|
|
1090
3074
|
fill="none"
|
|
1091
3075
|
stroke={pal.flowEdge}
|
|
1092
|
-
strokeWidth={
|
|
1093
|
-
opacity={(isLight ? 0.22 : 0.28) * fresh}
|
|
3076
|
+
strokeWidth={renderWidth}
|
|
3077
|
+
opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
|
|
1094
3078
|
filter={isLight ? undefined : 'url(#topo-glow)'}
|
|
1095
|
-
markerEnd=
|
|
1096
|
-
|
|
3079
|
+
markerEnd={`url(#${arrowId})`}
|
|
3080
|
+
data-edge-visible={link.key}
|
|
3081
|
+
style={{
|
|
3082
|
+
pointerEvents: 'none',
|
|
3083
|
+
transition: 'opacity 300ms ease-out, stroke-width 300ms ease-out',
|
|
3084
|
+
}}
|
|
1097
3085
|
/>
|
|
1098
3086
|
<path
|
|
1099
3087
|
id={`flow-path-${index}`}
|
|
@@ -1102,42 +3090,466 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1102
3090
|
stroke={pal.flowPath}
|
|
1103
3091
|
strokeWidth="1"
|
|
1104
3092
|
strokeDasharray="2 12"
|
|
1105
|
-
opacity={(isLight ? 0.4 : 0.75) * fresh}
|
|
1106
|
-
className="transition-opacity duration-
|
|
3093
|
+
opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
|
|
3094
|
+
className="transition-opacity duration-300"
|
|
1107
3095
|
/>
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
3096
|
+
{!reducedMotion && (
|
|
3097
|
+
/* Round 103 / Loop: phase-stagger the particles so
|
|
3098
|
+
concurrent edges don't pulse in lockstep. SMIL
|
|
3099
|
+
`begin` accepts negative offsets to shift the cycle
|
|
3100
|
+
backwards in time, which means the particle starts
|
|
3101
|
+
mid-flight on first paint — no visible "all
|
|
3102
|
+
particles spawn from source simultaneously" tell.
|
|
3103
|
+
`(index * 0.37) % duration` gives a deterministic,
|
|
3104
|
+
well-distributed offset (the golden-ratio-ish 0.37
|
|
3105
|
+
fraction prevents lining up when N is a small
|
|
3106
|
+
multiple). Edge order is stable (sorted by recent
|
|
3107
|
+
activity), so the offsets feel calm rather than
|
|
3108
|
+
reshuffling each refresh. */
|
|
3109
|
+
<circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
|
|
3110
|
+
<animateMotion
|
|
3111
|
+
dur={`${duration}s`}
|
|
3112
|
+
begin={`-${((index * 0.37) % duration).toFixed(3)}s`}
|
|
3113
|
+
repeatCount="indefinite"
|
|
3114
|
+
path={path}
|
|
3115
|
+
/>
|
|
3116
|
+
</circle>
|
|
3117
|
+
)}
|
|
3118
|
+
{/* Round 75 / Loop: arrival ping at the destination. The
|
|
3119
|
+
particle currently fades into the arrow marker
|
|
3120
|
+
silently — adding a small radiating ring synchronised
|
|
3121
|
+
to the particle's period turns message delivery into
|
|
3122
|
+
a visible event. begin = -dur*0.92 offsets the
|
|
3123
|
+
animation so the ring expands NEAR the end of each
|
|
3124
|
+
cycle (≈when the particle arrives). Gated by
|
|
3125
|
+
reducedMotion and on fresh > 0.5 — stale edges that
|
|
3126
|
+
haven't fired in minutes don't need the eye-grab.
|
|
3127
|
+
data-arrival-ping for testability. */}
|
|
3128
|
+
{!reducedMotion && fresh > 0.5 && (
|
|
3129
|
+
<circle
|
|
3130
|
+
cx={to.x}
|
|
3131
|
+
cy={to.y}
|
|
3132
|
+
r="0"
|
|
3133
|
+
fill="none"
|
|
3134
|
+
stroke={pal.flowEdge}
|
|
3135
|
+
strokeWidth="1.5"
|
|
3136
|
+
opacity="0"
|
|
3137
|
+
style={{ pointerEvents: 'none' }}
|
|
3138
|
+
data-arrival-ping={link.key}
|
|
3139
|
+
>
|
|
3140
|
+
<animate attributeName="r" values="0;14;22" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
|
|
3141
|
+
<animate attributeName="opacity" values="0;0.55;0" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
|
|
3142
|
+
</circle>
|
|
3143
|
+
)}
|
|
3144
|
+
{/* Round 76 / Loop: source dispatch pulse — mirror to the
|
|
3145
|
+
R75 arrival ping. begin = 0s (start of cycle) so the
|
|
3146
|
+
ring expands as the particle LEAVES the source. Only
|
|
3147
|
+
fires for high-traffic edges (link.count >= 3) — on
|
|
3148
|
+
quiet conversations the canvas should stay calm; on
|
|
3149
|
+
busy senders the pulse plus arrival ping bookend
|
|
3150
|
+
every message in flight, making the topology feel
|
|
3151
|
+
alive. Same fresh/reducedMotion gates as R75. Slightly
|
|
3152
|
+
smaller radius (0→12→18 vs R75's 0→14→22) so the
|
|
3153
|
+
source reads as "smaller event than arrival" — the
|
|
3154
|
+
destination is the meaningful endpoint. */}
|
|
3155
|
+
{!reducedMotion && fresh > 0.5 && link.count >= 3 && (
|
|
3156
|
+
<circle
|
|
3157
|
+
cx={from.x}
|
|
3158
|
+
cy={from.y}
|
|
3159
|
+
r="0"
|
|
3160
|
+
fill="none"
|
|
3161
|
+
stroke={pal.flowEdge}
|
|
3162
|
+
strokeWidth="1.5"
|
|
3163
|
+
opacity="0"
|
|
3164
|
+
style={{ pointerEvents: 'none' }}
|
|
3165
|
+
data-dispatch-pulse={link.key}
|
|
3166
|
+
>
|
|
3167
|
+
<animate attributeName="r" values="0;12;18" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
|
|
3168
|
+
<animate attributeName="opacity" values="0;0.45;0" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
|
|
3169
|
+
</circle>
|
|
3170
|
+
)}
|
|
3171
|
+
{/* Round 100 / Loop: midpoint count badge for high-
|
|
3172
|
+
traffic edges. Width already gauges intensity but
|
|
3173
|
+
"5 vs 12" is hard to read off stroke alone. Render
|
|
3174
|
+
a compact pill at the bezier midpoint (t=0.5 →
|
|
3175
|
+
midpoint + perpendicular × lift/2) showing the
|
|
3176
|
+
integer count. Threshold link.count >= 3 keeps
|
|
3177
|
+
the canvas quiet for sparse traffic. Bezier
|
|
3178
|
+
midpoint math: for a quadratic curve M A Q C B,
|
|
3179
|
+
the t=0.5 point is (A + 2C + B) / 4, which
|
|
3180
|
+
simplifies to (midAB) + 0.5 × (control - midAB)
|
|
3181
|
+
= midAB + perpendicular × lift/2. */}
|
|
3182
|
+
{link.count >= 3 && (() => {
|
|
3183
|
+
const midX = (from.x + to.x) / 2;
|
|
3184
|
+
const midY = (from.y + to.y) / 2;
|
|
3185
|
+
const dx = to.x - from.x;
|
|
3186
|
+
const dy = to.y - from.y;
|
|
3187
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
3188
|
+
const badgeX = midX + (-dy / len) * lift * 0.5;
|
|
3189
|
+
const badgeY = midY + ( dx / len) * lift * 0.5;
|
|
3190
|
+
const badgeOpacity = Math.min(1, fresh * edgeOpacityMul);
|
|
3191
|
+
/* R121: the badge becomes a canvas-side click-to-pin
|
|
3192
|
+
affordance. R100 introduced it as a passive count
|
|
3193
|
+
display; R116 added pinnedEdgeKey. Joining them
|
|
3194
|
+
lets users pin a flow directly from the canvas
|
|
3195
|
+
without crossing to the recent-signal panel.
|
|
3196
|
+
pointerEvents move from 'none' to 'all' on the
|
|
3197
|
+
wrapper <g>; the underlying R48 edge hitbox is
|
|
3198
|
+
wider (16 px) than the 18-px badge diameter so
|
|
3199
|
+
clicks landing on either still route correctly
|
|
3200
|
+
— the badge consumes its small footprint, the
|
|
3201
|
+
hitbox owns the rest. stopPropagation on
|
|
3202
|
+
pointerdown so the SVG pan capture doesn't
|
|
3203
|
+
redirect the click. */
|
|
3204
|
+
const isPinned = pinnedEdgeKey === link.key;
|
|
3205
|
+
// R126: hot-edge accent. The edge stroke width (line
|
|
3206
|
+
// 2344) already scales with count but clamps at 7 —
|
|
3207
|
+
// so count=5 and count=50 look identical at the line,
|
|
3208
|
+
// and the only signal differentiating them is the
|
|
3209
|
+
// integer in the badge. Bucket count into a "hot"
|
|
3210
|
+
// band (≥ 10) and flip the badge stroke to a warmer
|
|
3211
|
+
// tone + thicker ring so busy lanes telegraph at a
|
|
3212
|
+
// glance without reading the digit. Reuses the amber
|
|
3213
|
+
// family from R125 (chip empty-state) — semantic is
|
|
3214
|
+
// "draw the eye here"; R125 amber is in the chip row
|
|
3215
|
+
// above the SVG, R126 amber is on the canvas, so the
|
|
3216
|
+
// surfaces never compete in the same eye-sweep. Pin
|
|
3217
|
+
// still wins (uses legendHeadline) so a pinned hot
|
|
3218
|
+
// edge reads as "locked + busy" via the badge text
|
|
3219
|
+
// and edge brightness, not via a third stroke colour.
|
|
3220
|
+
const isHot = link.count >= 10;
|
|
3221
|
+
const hotStroke = isLight ? '#d97706' : '#fbbf24';
|
|
3222
|
+
return (
|
|
3223
|
+
<g
|
|
3224
|
+
data-edge-count-badge={link.key}
|
|
3225
|
+
data-edge-count-badge-pinned={isPinned ? 'true' : 'false'}
|
|
3226
|
+
data-edge-count-badge-hot={isHot ? 'true' : 'false'}
|
|
3227
|
+
role="button"
|
|
3228
|
+
tabIndex={0}
|
|
3229
|
+
aria-pressed={isPinned}
|
|
3230
|
+
className="anet-topo-svg-focus"
|
|
3231
|
+
style={{ pointerEvents: 'all', cursor: 'pointer' }}
|
|
3232
|
+
opacity={badgeOpacity}
|
|
3233
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
3234
|
+
// R122: badge hover propagates to hoveredEdgeKey so
|
|
3235
|
+
// moving the cursor onto the badge lights the
|
|
3236
|
+
// same endpoint rings + edge brighten as hovering
|
|
3237
|
+
// the line. R121 only wired click; the badge sat
|
|
3238
|
+
// visually separate from the line on hover,
|
|
3239
|
+
// which felt like two surfaces rather than one.
|
|
3240
|
+
onMouseEnter={() => setHoveredEdgeKey(link.key)}
|
|
3241
|
+
onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
|
|
3242
|
+
// Round 185 / Loop: edge badge click fires the same
|
|
3243
|
+
// one-shot expanding-ring ripple R14 uses for node
|
|
3244
|
+
// click and R52 uses for hub click — anchored at
|
|
3245
|
+
// the badge midpoint with the edge's own flowEdge
|
|
3246
|
+
// colour. Closes the click-feel idiom across all
|
|
3247
|
+
// three pinnable canvas surfaces (hub / node /
|
|
3248
|
+
// edge badge). Reused setClickRipple state machine
|
|
3249
|
+
// so only one ripple at a time; ts coordinate
|
|
3250
|
+
// guard in the setTimeout cleanup prevents an
|
|
3251
|
+
// older ripple from clobbering a newer one if
|
|
3252
|
+
// the user clicks two badges in quick succession.
|
|
3253
|
+
onClick={(e) => {
|
|
3254
|
+
e.stopPropagation();
|
|
3255
|
+
setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
|
|
3256
|
+
const ts = Date.now();
|
|
3257
|
+
setClickRipple({ ts, x: badgeX, y: badgeY, r0: 10.5, color: pal.flowEdge });
|
|
3258
|
+
setTimeout(() => setClickRipple(prev => prev && prev.ts === ts ? null : prev), 600);
|
|
3259
|
+
}}
|
|
3260
|
+
onKeyDown={(e) => {
|
|
3261
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
3262
|
+
e.preventDefault();
|
|
3263
|
+
setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
|
|
3264
|
+
const ts = Date.now();
|
|
3265
|
+
setClickRipple({ ts, x: badgeX, y: badgeY, r0: 10.5, color: pal.flowEdge });
|
|
3266
|
+
setTimeout(() => setClickRipple(prev => prev && prev.ts === ts ? null : prev), 600);
|
|
3267
|
+
}
|
|
3268
|
+
}}
|
|
3269
|
+
>
|
|
3270
|
+
<title>{isPinned
|
|
3271
|
+
? `${link.from} → ${link.to} (${link.count}) — click to release pin`
|
|
3272
|
+
: `${link.from} → ${link.to} (${link.count}) — click to pin`}</title>
|
|
3273
|
+
{/* Round 164 / Loop: edge badge gains hover-lift
|
|
3274
|
+
to match the 5-surface hover-elevation
|
|
3275
|
+
family (R51 node / R135 panel / R142 group
|
|
3276
|
+
box / R143 recent row / R144 legend row).
|
|
3277
|
+
Pre-R164 the badge had R122 hover→edge-brighten
|
|
3278
|
+
propagation but the badge ITSELF stayed
|
|
3279
|
+
static, so the cursor-on-target feedback
|
|
3280
|
+
felt mismatched with every other interactive
|
|
3281
|
+
surface. Bumping r 9 → 10.5 on hover OR pin
|
|
3282
|
+
gives the same "lift" gesture in canvas
|
|
3283
|
+
space (the badge sits on a curved path, so
|
|
3284
|
+
translate-Y wouldn't track the line; radius
|
|
3285
|
+
growth is the SVG-native equivalent). Pin
|
|
3286
|
+
and hover share the lift so a pinned badge
|
|
3287
|
+
stays visually raised even after mouseleave —
|
|
3288
|
+
mirrors R143/R144 where row pin gets the
|
|
3289
|
+
same lift as row hover. Pinned still keeps
|
|
3290
|
+
its R121 stroke change (legendHeadline +
|
|
3291
|
+
width 2) so pin and hover stay
|
|
3292
|
+
discriminable on the same lifted state.
|
|
3293
|
+
strokeWidth stays at 1 / 2 — won't trip the
|
|
3294
|
+
R51 overlap-test sentinels (1.5 / 3 are
|
|
3295
|
+
reserved). transition keeps the lift smooth
|
|
3296
|
+
(180ms ease-out) and respects prefers-
|
|
3297
|
+
reduced-motion via the globals.css blanket
|
|
3298
|
+
override that neutralises transitions.
|
|
3299
|
+
|
|
3300
|
+
Six surfaces now share the hover-elevation
|
|
3301
|
+
idiom: nodes (R51), panels (R135), group
|
|
3302
|
+
boxes (R142), recent rows (R143), legend
|
|
3303
|
+
rows (R144), edge badges (R164). */}
|
|
3304
|
+
{/* Round 188 / Loop: extend the badge transition to
|
|
3305
|
+
include stroke + stroke-width. R164 added the
|
|
3306
|
+
r 9↔10.5 lift; R188 closes the smoothness gap
|
|
3307
|
+
on the R121 pin-stroke flip (cyan flowEdge ↔
|
|
3308
|
+
legendHeadline) and R126 hot-lane flip (cyan
|
|
3309
|
+
↔ amber). Both used to snap when crossing the
|
|
3310
|
+
state boundary (pin click, or count crossing
|
|
3311
|
+
10). Now they ease 300ms through the colour
|
|
3312
|
+
and width change — same idiom R167 uses for
|
|
3313
|
+
the node status ring. The badge strokeWidth
|
|
3314
|
+
values are 1/2 (not R51 sentinels 1.5/3) so
|
|
3315
|
+
the always-rendered badge stays invisible to
|
|
3316
|
+
the overlap-test guard rails. */}
|
|
3317
|
+
<circle
|
|
3318
|
+
cx={badgeX} cy={badgeY}
|
|
3319
|
+
r={isHoveredEdge || isPinned ? 10.5 : 9}
|
|
3320
|
+
fill={pal.legendBox.fill}
|
|
3321
|
+
stroke={isPinned ? pal.legendHeadline : isHot ? hotStroke : pal.flowEdge}
|
|
3322
|
+
strokeWidth={isPinned ? 2 : isHot ? 2 : 1}
|
|
3323
|
+
opacity={isLight ? 0.95 : 0.82}
|
|
3324
|
+
data-edge-badge-lifted={(isHoveredEdge || isPinned) ? 'true' : 'false'}
|
|
3325
|
+
style={{ transition: 'r 180ms ease-out, stroke 300ms ease-out, stroke-width 300ms ease-out' }}
|
|
3326
|
+
/>
|
|
3327
|
+
<text
|
|
3328
|
+
x={badgeX} y={badgeY + 3}
|
|
3329
|
+
textAnchor="middle"
|
|
3330
|
+
fill={pal.legendHeadline}
|
|
3331
|
+
fontSize="10"
|
|
3332
|
+
fontFamily="monospace"
|
|
3333
|
+
fontWeight="700"
|
|
3334
|
+
style={{ pointerEvents: 'none' }}
|
|
3335
|
+
>{link.count}</text>
|
|
3336
|
+
</g>
|
|
3337
|
+
);
|
|
3338
|
+
})()}
|
|
1111
3339
|
</g>
|
|
1112
3340
|
);
|
|
1113
3341
|
})}
|
|
1114
3342
|
|
|
1115
|
-
{/* center hub — round 39
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
the
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
3343
|
+
{/* center hub — round 39 sized + round 13 restraint.
|
|
3344
|
+
The hub is the control-plane anchor. r39 gave it two outward
|
|
3345
|
+
pulses (r 10→38, 2.4s, double-phase) which read as "loudest
|
|
3346
|
+
node in the room" — wrong semantics: anchors don't emit,
|
|
3347
|
+
they hold. r13 swaps the kinetic pulse for a slow opacity
|
|
3348
|
+
breath on the static halo (4s, ±15% from base), so the hub
|
|
3349
|
+
stays alive without throwing kinetic energy outward. The
|
|
3350
|
+
dual-circle "lit lamp" core is unchanged. */}
|
|
3351
|
+
{layout === 'ring' && (<g
|
|
3352
|
+
data-topo-hub
|
|
3353
|
+
data-topo-hub-hovered={hoveredHub ? 'true' : 'false'}
|
|
3354
|
+
// Round 159 / Loop: the hub is the most visually
|
|
3355
|
+
// prominent interactive element on the canvas (R39
|
|
3356
|
+
// enlarged it, R52 made it click to fitView, R115
|
|
3357
|
+
// added a hover ring, R43 gave it a tooltip) — but
|
|
3358
|
+
// R151-R157's a11y sweep skipped it. role/tabIndex/
|
|
3359
|
+
// aria-label/onKeyDown bring it to parity with node <g>
|
|
3360
|
+
// (R151), group label hit (R152), and minimap (R157).
|
|
3361
|
+
// anet-topo-svg-focus picks up R156's explicit cyan
|
|
3362
|
+
// focus ring (default browser SVG focus rect is hard
|
|
3363
|
+
// to see against the dark canvas).
|
|
3364
|
+
role="button"
|
|
3365
|
+
tabIndex={0}
|
|
3366
|
+
aria-label={(() => {
|
|
3367
|
+
const parts = ['Network hub'];
|
|
3368
|
+
if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
|
|
3369
|
+
if (workingCount > 0) parts.push(`${workingCount} working`);
|
|
3370
|
+
if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
|
|
3371
|
+
return parts.join(' · ') + ' — Enter to fit view';
|
|
3372
|
+
})()}
|
|
3373
|
+
// Round 176 / Loop: hub joins the first-paint fade-in
|
|
3374
|
+
// family as the 6th surface. The hub is the visual anchor
|
|
3375
|
+
// — every other ring-layout reveal layer (R174 tier rings,
|
|
3376
|
+
// R9 nodes, R172 edges) emanates outward FROM it — yet
|
|
3377
|
+
// pre-R176 the hub itself popped in instantly while the
|
|
3378
|
+
// wave it should be leading staggered around it. Adding
|
|
3379
|
+
// .anet-fade-in at delay 0 (no animation-delay needed)
|
|
3380
|
+
// places the hub as the canvas-center anchor that the
|
|
3381
|
+
// tier wave grows from. Composes cleanly with the existing
|
|
3382
|
+
// anet-topo-svg-focus class (R159 keyboard focus ring).
|
|
3383
|
+
className="anet-topo-svg-focus anet-fade-in"
|
|
3384
|
+
data-topo-hub-fade-delay={0}
|
|
3385
|
+
style={{ cursor: 'pointer' }}
|
|
3386
|
+
// Stop pointerdown from reaching the SVG pan handler — same
|
|
3387
|
+
// reason as the node <g>: a captured pointer makes Chromium
|
|
3388
|
+
// fire the follow-up click on the SVG, not this group.
|
|
3389
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
3390
|
+
onMouseEnter={() => setHoveredHub(true)}
|
|
3391
|
+
onMouseLeave={() => setHoveredHub(false)}
|
|
3392
|
+
// Round 52 / Loop: hub is the visual anchor but had no click
|
|
3393
|
+
// action — users discovered fit-to-content only via the `f`
|
|
3394
|
+
// key or the chrome button. Wire the hub to fitView() so the
|
|
3395
|
+
// most prominent element in the canvas is also the "re-
|
|
3396
|
+
// center" affordance. A click-ripple keyed by ts confirms
|
|
3397
|
+
// the gesture; the hub <title> now ends with a hint.
|
|
3398
|
+
onClick={() => {
|
|
3399
|
+
fitView();
|
|
3400
|
+
setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
|
|
3401
|
+
setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
|
|
3402
|
+
}}
|
|
3403
|
+
onKeyDown={(e) => {
|
|
3404
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
3405
|
+
e.preventDefault();
|
|
3406
|
+
fitView();
|
|
3407
|
+
setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
|
|
3408
|
+
setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
|
|
3409
|
+
}
|
|
3410
|
+
}}
|
|
3411
|
+
>
|
|
3412
|
+
{/* Round 43 / Loop: hub `<title>` summary — hovering the
|
|
3413
|
+
central glow now answers "what is this?" with a one-line
|
|
3414
|
+
fleet snapshot. Duplicates the header chips by design;
|
|
3415
|
+
hovering the most visually prominent element is the
|
|
3416
|
+
most natural impulse, so satisfy it where the cursor
|
|
3417
|
+
already is. Falls clean: omits sub-clauses when their
|
|
3418
|
+
counts are zero. R52 appends a "click to fit view" hint.
|
|
3419
|
+
*/}
|
|
3420
|
+
<title>{(() => {
|
|
3421
|
+
const total = sessions.length;
|
|
3422
|
+
const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
|
|
3423
|
+
if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
|
|
3424
|
+
if (workingCount > 0) parts.push(`${workingCount} working`);
|
|
3425
|
+
if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
|
|
3426
|
+
return parts.join(' · ') + '\nclick to fit view';
|
|
3427
|
+
})()}</title>
|
|
3428
|
+
{/* grounding halo — breathes in opacity, no expansion.
|
|
3429
|
+
R84: breath amplitude + tempo reflect workingCount. An
|
|
3430
|
+
idle fleet keeps the original gentle 0.32→0.52 (light) /
|
|
3431
|
+
0.08→0.16 (dark) cycle over 4s — "heart at rest". As
|
|
3432
|
+
working sessions accumulate, the peak climbs and the
|
|
3433
|
+
period shortens, capped at 2.4s so it never feels
|
|
3434
|
+
manic. Quiet fleets see zero change; busy fleets feel
|
|
3435
|
+
the canvas working. */}
|
|
3436
|
+
{(() => {
|
|
3437
|
+
// Bucket workingCount to keep the visual feedback discrete
|
|
3438
|
+
// rather than continuous: 0 / 1-2 / 3-5 / 6+. Three buckets
|
|
3439
|
+
// are enough — finer gradations are imperceptible.
|
|
3440
|
+
const busy = workingCount === 0 ? 0
|
|
3441
|
+
: workingCount <= 2 ? 1
|
|
3442
|
+
: workingCount <= 5 ? 2
|
|
3443
|
+
: 3;
|
|
3444
|
+
const peakLight = [0.52, 0.58, 0.65, 0.72][busy];
|
|
3445
|
+
const peakDark = [0.16, 0.20, 0.26, 0.32][busy];
|
|
3446
|
+
const troughLight = 0.32;
|
|
3447
|
+
const troughDark = 0.08;
|
|
3448
|
+
const dur = [4.0, 3.2, 2.7, 2.4][busy];
|
|
3449
|
+
const valuesLight = `${troughLight};${peakLight};${troughLight}`;
|
|
3450
|
+
const valuesDark = `${troughDark};${peakDark};${troughDark}`;
|
|
3451
|
+
return (
|
|
3452
|
+
<circle
|
|
3453
|
+
cx={cx} cy={cy} r="18"
|
|
3454
|
+
fill={isLight ? '#d1fae5' : '#10b981'}
|
|
3455
|
+
opacity={isLight ? 0.42 : 0.12}
|
|
3456
|
+
data-hub-busyness={busy}
|
|
3457
|
+
>
|
|
3458
|
+
{!reducedMotion && (
|
|
3459
|
+
<animate
|
|
3460
|
+
attributeName="opacity"
|
|
3461
|
+
values={isLight ? valuesLight : valuesDark}
|
|
3462
|
+
dur={`${dur}s`}
|
|
3463
|
+
repeatCount="indefinite"
|
|
3464
|
+
/>
|
|
3465
|
+
)}
|
|
3466
|
+
</circle>
|
|
3467
|
+
);
|
|
3468
|
+
})()}
|
|
1138
3469
|
{/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
|
|
1139
3470
|
<circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
|
|
1140
|
-
|
|
3471
|
+
{/* R130 / Loop: when workingCount > 0, the decorative inner
|
|
3472
|
+
highlight gets replaced with the workingCount digit. The
|
|
3473
|
+
R84 busyness breath already encodes the same metric
|
|
3474
|
+
through motion — adding the digit gives it a second
|
|
3475
|
+
visual channel right at the canvas's focal point. A
|
|
3476
|
+
user glancing at the hub now sees both "the network is
|
|
3477
|
+
pulsing" (motion) AND "3 agents are working" (digit)
|
|
3478
|
+
without having to scan the chip row or panels.
|
|
3479
|
+
|
|
3480
|
+
Geometry: text at (cx, cy) with fontSize 11 monospace
|
|
3481
|
+
+ fontWeight 700 sits inside the r=10 core (a 2-digit
|
|
3482
|
+
12 reads ~12 px wide × 11 px tall, well inside the
|
|
3483
|
+
20-px diameter core). Centered vertically via dy=
|
|
3484
|
+
"0.36em" — the standard SVG trick for text-vertical-
|
|
3485
|
+
center without measuring fontMetrics.
|
|
3486
|
+
|
|
3487
|
+
pointerEvents:none so the digit can't intercept the
|
|
3488
|
+
hub click (R52 fit-to-view still fires).
|
|
3489
|
+
|
|
3490
|
+
workingCount=0 falls through to the existing
|
|
3491
|
+
decorative highlight so the hub never looks empty. */}
|
|
3492
|
+
{workingCount > 0 ? (
|
|
3493
|
+
<text
|
|
3494
|
+
x={cx} y={cy}
|
|
3495
|
+
textAnchor="middle"
|
|
3496
|
+
dy="0.36em"
|
|
3497
|
+
fill={isLight ? '#d1fae5' : '#ecfdf5'}
|
|
3498
|
+
fontSize="11"
|
|
3499
|
+
fontFamily="monospace"
|
|
3500
|
+
fontWeight="700"
|
|
3501
|
+
style={{ pointerEvents: 'none' }}
|
|
3502
|
+
data-topo-hub-working-count={workingCount}
|
|
3503
|
+
>
|
|
3504
|
+
{workingCount}
|
|
3505
|
+
</text>
|
|
3506
|
+
) : (
|
|
3507
|
+
<circle
|
|
3508
|
+
cx={cx} cy={cy} r="5"
|
|
3509
|
+
fill="#d1fae5"
|
|
3510
|
+
opacity="0.9"
|
|
3511
|
+
data-topo-hub-highlight
|
|
3512
|
+
/>
|
|
3513
|
+
)}
|
|
3514
|
+
{/* R115 / Loop: hover hint ring. Stroke-only circle at r=14
|
|
3515
|
+
that fades in when the hub is hovered — the same idea
|
|
3516
|
+
R44 used for node avatars (group-hover stroke). r=14
|
|
3517
|
+
sits comfortably outside the r=10 core and INSIDE the
|
|
3518
|
+
r=18 grounding halo, so the hover indicator is fully
|
|
3519
|
+
contained within the existing hub footprint (no bbox
|
|
3520
|
+
growth, overlap test unchanged). pointerEvents:none so
|
|
3521
|
+
the hint can't intercept the click that produced it. */}
|
|
3522
|
+
{/* Round 177 / Loop: hub hover ring picks up the same
|
|
3523
|
+
"lift on hover" gesture R164 added to the edge midpoint
|
|
3524
|
+
badge (r=9→10.5). Pre-R177 the ring faded opacity 0→0.7
|
|
3525
|
+
on hover but stayed static at r=14. Adding r=14→17 on
|
|
3526
|
+
hover gives the gesture extra weight — the hub responds
|
|
3527
|
+
more confidently. Stays within the r=18 halo bbox (no
|
|
3528
|
+
geometry growth), so the R51 overlap-test guard rails
|
|
3529
|
+
still hold. strokeWidth=1.5 is the offline-node
|
|
3530
|
+
sentinel but the overlap-test selector is gated to
|
|
3531
|
+
`g[data-node]` ancestors — this hub-internal circle
|
|
3532
|
+
is invisible to that probe. transition list grows `r`
|
|
3533
|
+
alongside opacity so both ease in concert; the lift
|
|
3534
|
+
feels like one continuous gesture. Seventh surface in
|
|
3535
|
+
the hover-elevation family (R51 nodes, R135 panels,
|
|
3536
|
+
R142 group boxes, R143/R144 rows, R164 edge badges,
|
|
3537
|
+
R177 hub ring). prefers-reduced-motion respected via
|
|
3538
|
+
R29 globals.css blanket. */}
|
|
3539
|
+
<circle
|
|
3540
|
+
cx={cx} cy={cy}
|
|
3541
|
+
r={hoveredHub ? 17 : 14}
|
|
3542
|
+
fill="none"
|
|
3543
|
+
stroke={isLight ? '#059669' : '#10b981'}
|
|
3544
|
+
strokeWidth="1.5"
|
|
3545
|
+
opacity={hoveredHub ? (isLight ? 0.85 : 0.7) : 0}
|
|
3546
|
+
data-topo-hub-hover-ring
|
|
3547
|
+
data-topo-hub-hover-ring-radius={hoveredHub ? 17 : 14}
|
|
3548
|
+
style={{
|
|
3549
|
+
pointerEvents: 'none',
|
|
3550
|
+
transition: 'opacity 180ms ease-out, r 180ms ease-out',
|
|
3551
|
+
}}
|
|
3552
|
+
/>
|
|
1141
3553
|
</g>)}
|
|
1142
3554
|
|
|
1143
3555
|
{/* agent nodes */}
|
|
@@ -1164,22 +3576,113 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1164
3576
|
// Round 8: when a node in another group is hovered, fade this
|
|
1165
3577
|
// one. Same-group nodes (incl. singletons matching the hovered
|
|
1166
3578
|
// alias) stay full. Pure visual focus, geometry unchanged.
|
|
1167
|
-
const inFocus = !
|
|
3579
|
+
const inFocus = !activeGroup || (groupKeys[session.alias] ?? session.alias) === activeGroup;
|
|
3580
|
+
// R72: in ring layout, classify the node into a tier by its
|
|
3581
|
+
// distance from hub centre so the first-paint stagger can
|
|
3582
|
+
// emanate inner → outer instead of running clockwise. Grid
|
|
3583
|
+
// layout doesn't have a radial structure; it keeps R9's pure
|
|
3584
|
+
// nodeIdx stagger. Thresholds bracket the actual tier radii
|
|
3585
|
+
// (single 220, dual 175/260, triple 145/215/285, offline 325+).
|
|
3586
|
+
let tierIdx = 0;
|
|
3587
|
+
if (layout === 'ring') {
|
|
3588
|
+
const p = nodePositions[session.alias];
|
|
3589
|
+
if (p) {
|
|
3590
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
3591
|
+
tierIdx = d < 195 ? 0 : d < 270 ? 1 : d < 310 ? 2 : 3;
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
1168
3594
|
|
|
1169
3595
|
return (
|
|
1170
3596
|
<g
|
|
1171
3597
|
key={session.alias}
|
|
1172
3598
|
data-node={session.alias}
|
|
3599
|
+
data-tier-idx={layout === 'ring' ? tierIdx : -1}
|
|
3600
|
+
// R151 / Loop: node a11y compliance — match the pattern
|
|
3601
|
+
// R116 / R139 / R140 / R148 / R149 applied to other
|
|
3602
|
+
// interactive surfaces. Node <g> has been clickable for
|
|
3603
|
+
// chat since R45 but tab-unreachable + screen-reader-
|
|
3604
|
+
// unannounced. role="button" + tabIndex=0 + aria-label
|
|
3605
|
+
// expose it; aria-pressed reflects chat-target state so
|
|
3606
|
+
// SR users know which node currently has the popover
|
|
3607
|
+
// open. onKeyDown for Enter / Space fires the same
|
|
3608
|
+
// setChatAlias path as onClick. preventDefault on
|
|
3609
|
+
// Space stops the SVG canvas from scrolling.
|
|
3610
|
+
role="button"
|
|
3611
|
+
tabIndex={0}
|
|
3612
|
+
aria-pressed={chatAlias === session.alias}
|
|
3613
|
+
aria-label={`Chat with ${session.alias} (${session.status})`}
|
|
3614
|
+
onKeyDown={(e) => {
|
|
3615
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
3616
|
+
e.preventDefault();
|
|
3617
|
+
setChatAlias(session.alias);
|
|
3618
|
+
setClickRipple({
|
|
3619
|
+
ts: Date.now(),
|
|
3620
|
+
x: pos.x, y: pos.y, r0: radius,
|
|
3621
|
+
color: status.primary,
|
|
3622
|
+
});
|
|
3623
|
+
setTimeout(() => setClickRipple(prev =>
|
|
3624
|
+
prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
|
|
3625
|
+
}
|
|
3626
|
+
}}
|
|
1173
3627
|
// Round 3 / Loop: `anet-fade-in` runs once when the <g>
|
|
1174
3628
|
// mounts — a new session entering the fleet (or the topology
|
|
1175
3629
|
// first rendering) eases in instead of popping. Re-renders of
|
|
1176
3630
|
// an existing node don't re-trigger (React preserves the <g>
|
|
1177
3631
|
// via the alias key), so status changes don't flicker. The
|
|
1178
3632
|
// global prefers-reduced-motion sweep already neutralises it.
|
|
1179
|
-
className="group transition-opacity anet-fade-in"
|
|
3633
|
+
className="group transition-opacity anet-fade-in anet-topo-svg-focus"
|
|
1180
3634
|
style={{
|
|
1181
3635
|
cursor: 'pointer',
|
|
1182
|
-
|
|
3636
|
+
// Round 17 / Loop: offline nodes drop to 0.6 at rest so
|
|
3637
|
+
// online nodes pop without losing the offline-as-ghost
|
|
3638
|
+
// information. The dashed stroke + smaller radius already
|
|
3639
|
+
// say "offline"; dimming the whole group strengthens the
|
|
3640
|
+
// online-vs-offline hierarchy at first glance. Exempt the
|
|
3641
|
+
// chat-focused node — if the user explicitly opened a
|
|
3642
|
+
// popover targeting an offline alias, that node stays
|
|
3643
|
+
// full-brightness so the focus ring + popover read as one
|
|
3644
|
+
// selected thing rather than a dimmed selection. Group-
|
|
3645
|
+
// hover fade (Round 8) still wins when inFocus is false.
|
|
3646
|
+
// Round 49 / Loop: edge-hover endpoint highlight composes
|
|
3647
|
+
// OVER the inFocus/online formula — a non-endpoint node
|
|
3648
|
+
// dims to 0.28 (just below the inFocus 0.32 to read as a
|
|
3649
|
+
// stronger "not relevant" signal), endpoints keep their
|
|
3650
|
+
// base opacity. chatAlias still wins to keep the focus
|
|
3651
|
+
// ring legible if the user clicked through.
|
|
3652
|
+
// Round 55 / Loop: legend-status hover composes on the
|
|
3653
|
+
// SAME level as edge-hover-endpoint. Non-matching nodes
|
|
3654
|
+
// dim to 0.28; matching nodes stay at their base.
|
|
3655
|
+
// activeStatus matches: working = status==='working',
|
|
3656
|
+
// idle = online but not working, offline = !isOnline.
|
|
3657
|
+
// Round 60 / Loop: activeStatus = hoveredStatus ?? pinnedStatus
|
|
3658
|
+
// so the pressure-bar segment pins (R60) and the legend
|
|
3659
|
+
// row hover (R55) feed the same branch.
|
|
3660
|
+
// Round 80 / Loop: vendor-letter hover composes ABOVE the
|
|
3661
|
+
// activeStatus branch so hovering the vendor chip's `C`
|
|
3662
|
+
// dims everything except C-vendor nodes. Same dim value
|
|
3663
|
+
// (0.28) as edge-endpoint and status filters — visually
|
|
3664
|
+
// consistent. Vendor lookup uses the same vendorForModel
|
|
3665
|
+
// helper the avatar render uses, keyed by initial so the
|
|
3666
|
+
// chip and the avatar always agree on grouping.
|
|
3667
|
+
opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
|
|
3668
|
+
? 0.28
|
|
3669
|
+
: activeVendor && chatAlias !== session.alias && (() => {
|
|
3670
|
+
const v = vendorForModel(session.model);
|
|
3671
|
+
const initial = v.id === 'unknown' ? '?' : v.initial;
|
|
3672
|
+
return initial !== activeVendor;
|
|
3673
|
+
})()
|
|
3674
|
+
? 0.28
|
|
3675
|
+
: activeStatus && chatAlias !== session.alias && !(
|
|
3676
|
+
activeStatus === 'working' ? session.status === 'working'
|
|
3677
|
+
: activeStatus === 'idle' ? (isOnline && session.status !== 'working')
|
|
3678
|
+
: /* offline */ !isOnline
|
|
3679
|
+
)
|
|
3680
|
+
? 0.28
|
|
3681
|
+
: !inFocus
|
|
3682
|
+
? 0.32
|
|
3683
|
+
: chatAlias === session.alias
|
|
3684
|
+
? 1
|
|
3685
|
+
: isOnline ? 1 : 0.6,
|
|
1183
3686
|
// Round 9 / Loop: stagger the anet-fade-in so the topology
|
|
1184
3687
|
// reveals as a wave on first paint instead of one big pop.
|
|
1185
3688
|
// Cap at 24 indices (≈600ms tail) so 50-node fleets still
|
|
@@ -1187,7 +3690,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1187
3690
|
// applies during the keyframe — re-renders without a new
|
|
1188
3691
|
// mount (same alias key) don't replay, so status changes
|
|
1189
3692
|
// never trigger the stagger again.
|
|
1190
|
-
|
|
3693
|
+
// Round 72 / Loop: in ring layout, stagger by tier radius
|
|
3694
|
+
// so the topology emanates from the hub outward — inner
|
|
3695
|
+
// tier at 0ms, middle at ~180ms, outer at ~360ms, offline
|
|
3696
|
+
// at ~540ms. A small within-tier offset (nodeIdx % 6 * 25)
|
|
3697
|
+
// adds variety so each ring rotates in instead of all-at-
|
|
3698
|
+
// once popping. Grid keeps R9's pure nodeIdx stagger.
|
|
3699
|
+
animationDelay: layout === 'ring'
|
|
3700
|
+
? `${tierIdx * 180 + (nodeIdx % 6) * 25}ms`
|
|
3701
|
+
: `${Math.min(nodeIdx, 24) * 25}ms`,
|
|
3702
|
+
// Round 51 / Loop: hover micro-lift. The label already
|
|
3703
|
+
// lifts on group-hover (R26). The node body — circle,
|
|
3704
|
+
// avatar, status ring — stays planted, so the gesture
|
|
3705
|
+
// reads "label moves, body doesn't". Now the whole <g>
|
|
3706
|
+
// translates -2px when hovered: avatar + ring + label
|
|
3707
|
+
// move as one unit. 2 px is well inside the inter-row
|
|
3708
|
+
// gap (cellH headroom ≥22), and CSS transform on SVG
|
|
3709
|
+
// <g> never affects the overlap-test geometry (the
|
|
3710
|
+
// test never simulates hover). useReducedMotion drops
|
|
3711
|
+
// the lift to 0 for prefers-reduced-motion users.
|
|
3712
|
+
transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
|
|
3713
|
+
transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
|
|
1191
3714
|
}}
|
|
1192
3715
|
// Stop the pointerdown from reaching the SVG pan handler: the
|
|
1193
3716
|
// SVG calls setPointerCapture, and a captured pointer makes
|
|
@@ -1200,12 +3723,97 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1200
3723
|
// Round 8 group-focus fade works at any fleet size.
|
|
1201
3724
|
onPointerEnter={() => setHoveredAlias(session.alias)}
|
|
1202
3725
|
onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
|
|
1203
|
-
onClick={() =>
|
|
3726
|
+
onClick={() => {
|
|
3727
|
+
setChatAlias(session.alias);
|
|
3728
|
+
// Round 14 ripple — capture position, radius and status
|
|
3729
|
+
// colour at click time so the one-shot circle is self-
|
|
3730
|
+
// contained (no re-render-time recomputation needed).
|
|
3731
|
+
setClickRipple({
|
|
3732
|
+
ts: Date.now(),
|
|
3733
|
+
x: pos.x, y: pos.y, r0: radius,
|
|
3734
|
+
color: status.primary,
|
|
3735
|
+
});
|
|
3736
|
+
setTimeout(() => setClickRipple(prev =>
|
|
3737
|
+
prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
|
|
3738
|
+
}}
|
|
1204
3739
|
>
|
|
1205
3740
|
{/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
|
|
1206
3741
|
Falls back to just the alias when the node reports no
|
|
1207
|
-
model/runtime.
|
|
1208
|
-
|
|
3742
|
+
model/runtime.
|
|
3743
|
+
Round 33 / Loop: cwd line answers "what is this agent on?".
|
|
3744
|
+
Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
|
|
3745
|
+
so the operator knows whether to wait or chase. Online nodes
|
|
3746
|
+
skip the line (Round 27's 1 h ghost age-out means anything
|
|
3747
|
+
still online has heartbeated recently — the line would just
|
|
3748
|
+
be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
|
|
3749
|
+
("… 06:00:28" assumed UTC) formats. */}
|
|
3750
|
+
{(() => {
|
|
3751
|
+
// Round 35 / Loop: TZ-safe parsing via the shared lib helper
|
|
3752
|
+
// — same parseHubTime is used by isGhost above (Round 38
|
|
3753
|
+
// factored it to app/lib/time.ts so both paths interpret SQL
|
|
3754
|
+
// bare timestamps as UTC on every browser).
|
|
3755
|
+
const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
|
|
3756
|
+
// Round 98 / Loop: enrich the node tooltip with status,
|
|
3757
|
+
// group membership, and inbound/outbound flow counts —
|
|
3758
|
+
// same info-density spirit as R97 active-filter pill
|
|
3759
|
+
// tooltips. Hovering any node now answers "what is this
|
|
3760
|
+
// and how does it sit in the topology" without forcing
|
|
3761
|
+
// a click into the chat popover. Group line only shows
|
|
3762
|
+
// when the alias is part of a multi-member band (R106
|
|
3763
|
+
// prefix grouping); singletons skip it. Flow line only
|
|
3764
|
+
// when at least one direction has count > 0.
|
|
3765
|
+
const groupKey = groupKeys[session.alias];
|
|
3766
|
+
const groupMembers = groupKey
|
|
3767
|
+
? Object.values(groupKeys).filter(k => k === groupKey).length
|
|
3768
|
+
: 1;
|
|
3769
|
+
const groupLine = groupMembers > 1 ? `group: ${groupKey} · ${groupMembers}` : null;
|
|
3770
|
+
// R147 / Loop: node tooltip extends R98's flow summary
|
|
3771
|
+
// with the actual sender / receiver aliases. R98 told you
|
|
3772
|
+
// "12 in / 5 out" but not WHO. The R97 idiom — anywhere
|
|
3773
|
+
// the UI shows "N" should hover-explain WHICH N — applied
|
|
3774
|
+
// to filter pills, group labels, vendor letters, pressure
|
|
3775
|
+
// segments, recent-row text, active-links chip; the node
|
|
3776
|
+
// title was the last surface still showing only the
|
|
3777
|
+
// aggregate. Direction-tagged: senders are who's
|
|
3778
|
+
// messaging THIS node (inbound), receivers are who this
|
|
3779
|
+
// node is messaging (outbound). 6-truncate + "+N more"
|
|
3780
|
+
// matches the pattern other tooltips use so a 30-flow
|
|
3781
|
+
// node doesn't paint a 30-line tooltip.
|
|
3782
|
+
let flowIn = 0, flowOut = 0;
|
|
3783
|
+
const sendersMap = new Map<string, number>(); // alias → count, inbound
|
|
3784
|
+
const receiversMap = new Map<string, number>(); // alias → count, outbound
|
|
3785
|
+
for (const fl of flowLinks) {
|
|
3786
|
+
if (fl.from === session.alias) {
|
|
3787
|
+
flowOut += fl.count;
|
|
3788
|
+
receiversMap.set(fl.to, (receiversMap.get(fl.to) ?? 0) + fl.count);
|
|
3789
|
+
}
|
|
3790
|
+
if (fl.to === session.alias) {
|
|
3791
|
+
flowIn += fl.count;
|
|
3792
|
+
sendersMap.set(fl.from, (sendersMap.get(fl.from) ?? 0) + fl.count);
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
const fmtPeers = (m: Map<string, number>) => {
|
|
3796
|
+
const pairs = [...m.entries()].sort((a, b) => b[1] - a[1]);
|
|
3797
|
+
const preview = pairs.slice(0, 6).map(([a, n]) => `${a} (${n})`).join(', ');
|
|
3798
|
+
const suffix = pairs.length > 6 ? ` + ${pairs.length - 6} more` : '';
|
|
3799
|
+
return preview + suffix;
|
|
3800
|
+
};
|
|
3801
|
+
const flowLine = (flowIn + flowOut) > 0 ? `flows: ${flowIn} in / ${flowOut} out` : null;
|
|
3802
|
+
const sendersLine = sendersMap.size > 0 ? `← from: ${fmtPeers(sendersMap)}` : null;
|
|
3803
|
+
const receiversLine = receiversMap.size > 0 ? `→ to: ${fmtPeers(receiversMap)}` : null;
|
|
3804
|
+
return (
|
|
3805
|
+
<title>{[
|
|
3806
|
+
`${session.alias} · ${session.status}`,
|
|
3807
|
+
identityLine(session.model, session.runtime),
|
|
3808
|
+
groupLine,
|
|
3809
|
+
session.project_dir ? `cwd: ${session.project_dir}` : null,
|
|
3810
|
+
lastSeen ? `last seen: ${lastSeen}` : null,
|
|
3811
|
+
flowLine,
|
|
3812
|
+
sendersLine,
|
|
3813
|
+
receiversLine,
|
|
3814
|
+
].filter(Boolean).join('\n')}</title>
|
|
3815
|
+
);
|
|
3816
|
+
})()}
|
|
1209
3817
|
{/* Round 2 / Loop: hover ring — a thin outer stroke that fades
|
|
1210
3818
|
in when the cursor enters the node, signalling clickability
|
|
1211
3819
|
(real-user feedback for the chat-popover open). Pure CSS via
|
|
@@ -1233,21 +3841,63 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1233
3841
|
clear of the overlap-test selectors (1.5 / 3). Sits just
|
|
1234
3842
|
outside the halo radius+8 so it never overlaps a neighbour
|
|
1235
3843
|
(halos already pack flush in dense grids). */}
|
|
1236
|
-
{
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
3844
|
+
{/* R51 chat-target ring. R120 / Loop: gentle SMIL
|
|
3845
|
+
breath on the ring's opacity (±0.1 over 3s) when
|
|
3846
|
+
chat is open + !reducedMotion. Says "active session
|
|
3847
|
+
here" continuously without animation noise — the
|
|
3848
|
+
ring only appears for one node at a time (the
|
|
3849
|
+
chatAlias), so it never competes with R84 hub
|
|
3850
|
+
busyness or R112 working halo for attention.
|
|
3851
|
+
|
|
3852
|
+
Round 183 / Loop: 7th surface in the smooth-pin-
|
|
3853
|
+
mirror family. Pre-R183 the ring was conditionally
|
|
3854
|
+
mounted on `chatAlias === session.alias`; the
|
|
3855
|
+
className `transition-opacity duration-200` never
|
|
3856
|
+
fired because the element didn't exist before
|
|
3857
|
+
mount. Always-mounted now with opacity gated by
|
|
3858
|
+
isChat — the CSS transition fires cleanly on
|
|
3859
|
+
chat-close (smooth fade-out). The `<animate>`
|
|
3860
|
+
SMIL stays gated by `!reducedMotion && isChat`
|
|
3861
|
+
so it only runs for the active chat target; when
|
|
3862
|
+
chat is closed, SMIL unmounts and opacity reverts
|
|
3863
|
+
to attribute (0) → CSS transitions down smoothly.
|
|
3864
|
+
On chat-OPEN the SMIL takes over per spec, so the
|
|
3865
|
+
fade-in is snappier than the fade-out — acceptable
|
|
3866
|
+
because the user explicitly clicked the node.
|
|
3867
|
+
|
|
3868
|
+
strokeWidth=2.5 is not a R51 sentinel value
|
|
3869
|
+
(sentinels are 1.5/3 inside g[data-node]), so the
|
|
3870
|
+
ring is invisible to the overlap-test selector
|
|
3871
|
+
even when always-mounted. */}
|
|
3872
|
+
{(() => {
|
|
3873
|
+
const isChat = chatAlias === session.alias;
|
|
3874
|
+
return (
|
|
3875
|
+
<circle
|
|
3876
|
+
cx={pos.x}
|
|
3877
|
+
cy={pos.y}
|
|
3878
|
+
r={radius + 14}
|
|
3879
|
+
fill="none"
|
|
3880
|
+
stroke={status.primary}
|
|
3881
|
+
strokeWidth="2.5"
|
|
3882
|
+
opacity={isChat ? (isLight ? 0.85 : 0.95) : 0}
|
|
3883
|
+
filter={!isLight && isChat ? 'url(#topo-glow)' : undefined}
|
|
3884
|
+
style={{ pointerEvents: 'none', transition: 'opacity 200ms ease-out' }}
|
|
3885
|
+
data-chat-target-ring
|
|
3886
|
+
data-chat-target-active={isChat ? 'true' : 'false'}
|
|
3887
|
+
data-chat-target-breath={!reducedMotion && isChat ? 'on' : 'off'}
|
|
3888
|
+
>
|
|
3889
|
+
{!reducedMotion && isChat && (
|
|
3890
|
+
<animate
|
|
3891
|
+
attributeName="opacity"
|
|
3892
|
+
values={isLight ? '0.72;0.95;0.72' : '0.82;1;0.82'}
|
|
3893
|
+
dur="3s"
|
|
3894
|
+
repeatCount="indefinite"
|
|
3895
|
+
/>
|
|
3896
|
+
)}
|
|
3897
|
+
</circle>
|
|
3898
|
+
);
|
|
3899
|
+
})()}
|
|
3900
|
+
{isActive && !reducedMotion && (
|
|
1251
3901
|
<circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
|
|
1252
3902
|
<animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
|
|
1253
3903
|
<animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
|
|
@@ -1255,7 +3905,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1255
3905
|
)}
|
|
1256
3906
|
{/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
|
|
1257
3907
|
status colour changes so idle↔working↔offline doesn't snap
|
|
1258
|
-
— task replies / node-rename / SSE updates ease in.
|
|
3908
|
+
— task replies / node-rename / SSE updates ease in.
|
|
3909
|
+
Round 112 / Loop: working nodes get a subtle halo breath
|
|
3910
|
+
(±0.12 opacity at 3s cycle) so the eye can find "what's
|
|
3911
|
+
busy" at a glance without scanning chips. Idle + offline
|
|
3912
|
+
halos stay flat — they don't need to demand attention.
|
|
3913
|
+
R84 hub-center breath stays the loudest "fleet busyness"
|
|
3914
|
+
signal; this one is quieter, per-node. SMIL `<animate>`
|
|
3915
|
+
inside the circle, gated by reducedMotion. */}
|
|
1259
3916
|
<circle
|
|
1260
3917
|
cx={pos.x}
|
|
1261
3918
|
cy={pos.y}
|
|
@@ -1263,7 +3920,84 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1263
3920
|
fill={status.halo}
|
|
1264
3921
|
opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
|
|
1265
3922
|
className="transition-[fill,opacity] duration-300 ease-out"
|
|
1266
|
-
|
|
3923
|
+
data-node-halo-breath={!reducedMotion && session.status === 'working' ? 'on' : 'off'}
|
|
3924
|
+
>
|
|
3925
|
+
{!reducedMotion && session.status === 'working' && (
|
|
3926
|
+
<animate
|
|
3927
|
+
attributeName="opacity"
|
|
3928
|
+
values={isLight ? '0.73;0.92;0.73' : '0.53;0.78;0.53'}
|
|
3929
|
+
dur="3s"
|
|
3930
|
+
repeatCount="indefinite"
|
|
3931
|
+
/>
|
|
3932
|
+
)}
|
|
3933
|
+
</circle>
|
|
3934
|
+
{/* Round 111 / Loop: edge-endpoint emphasis ring. R49
|
|
3935
|
+
already keeps endpoint nodes at opacity 1 while
|
|
3936
|
+
others dim when an edge is hovered, but the
|
|
3937
|
+
endpoints had no POSITIVE indicator — they just
|
|
3938
|
+
"stayed bright". An accent stroke at r=radius+7
|
|
3939
|
+
(just inside the halo's r=radius+8 bbox so we
|
|
3940
|
+
don't grow the overlap footprint) clearly says
|
|
3941
|
+
"these are the two participants in this flow".
|
|
3942
|
+
pointerEvents:none so the node hitbox stays alive.
|
|
3943
|
+
|
|
3944
|
+
Round 182 / Loop: the ring used to mount/unmount
|
|
3945
|
+
on every edge hover, snapping despite the
|
|
3946
|
+
opacity transition on the style. Always-mount
|
|
3947
|
+
with opacity gated by hoveredEdgeEndpoints — same
|
|
3948
|
+
pattern R181 used for the legend pin ring. The
|
|
3949
|
+
transition now actually fires on hover entry
|
|
3950
|
+
and exit. 6th surface in the smooth-pin-mirror
|
|
3951
|
+
family (R165/R180/R181 + this round).
|
|
3952
|
+
|
|
3953
|
+
strokeWidth=1.6 (was 1.5) deliberately escapes
|
|
3954
|
+
the R51 overlap-test sentinel `circle[stroke-
|
|
3955
|
+
width="1.5"]`: an always-mounted r=radius+7 ring
|
|
3956
|
+
inside g[data-node] would otherwise be picked
|
|
3957
|
+
before the actual status ring (r=radius) by
|
|
3958
|
+
querySelector document order, breaking the
|
|
3959
|
+
test's node-bbox read. 1.5 → 1.6 is visually
|
|
3960
|
+
imperceptible (6.7% thicker) but the exact-
|
|
3961
|
+
string CSS attribute selector no longer
|
|
3962
|
+
matches. */}
|
|
3963
|
+
{(() => {
|
|
3964
|
+
const isEndpoint = hoveredEdgeEndpoints && hoveredEdgeEndpoints.has(session.alias);
|
|
3965
|
+
return (
|
|
3966
|
+
<circle
|
|
3967
|
+
cx={pos.x}
|
|
3968
|
+
cy={pos.y}
|
|
3969
|
+
r={radius + 7}
|
|
3970
|
+
fill="none"
|
|
3971
|
+
stroke={pal.flowEdge}
|
|
3972
|
+
strokeWidth={1.6}
|
|
3973
|
+
opacity={isEndpoint ? (isLight ? 0.9 : 0.85) : 0}
|
|
3974
|
+
data-edge-endpoint-ring
|
|
3975
|
+
data-edge-endpoint-active={isEndpoint ? 'true' : 'false'}
|
|
3976
|
+
style={{ pointerEvents: 'none', transition: 'opacity 180ms ease-out' }}
|
|
3977
|
+
/>
|
|
3978
|
+
);
|
|
3979
|
+
})()}
|
|
3980
|
+
{/* Round 167 / Loop: extend the node status-ring
|
|
3981
|
+
transition to include stroke-width — symmetric
|
|
3982
|
+
with R165 (pressure-bar width) and R166 (edge
|
|
3983
|
+
stroke-width). Pre-R167 only fill+stroke colors
|
|
3984
|
+
transitioned smoothly; stroke-width snapped from
|
|
3985
|
+
3 (online) to 1.5 (offline) when a session
|
|
3986
|
+
transitioned. With stroke-width in the transition
|
|
3987
|
+
list the ring smoothly contracts as a node goes
|
|
3988
|
+
offline (or expands when it comes back).
|
|
3989
|
+
strokeDasharray stays binary (none ↔ '5 5')
|
|
3990
|
+
because dash values don't interpolate cleanly
|
|
3991
|
+
between continuous and discrete forms.
|
|
3992
|
+
Inline style replaces the Tailwind transition-
|
|
3993
|
+
[fill,stroke] className for stable arbitrary
|
|
3994
|
+
property compilation. Respects prefers-reduced-
|
|
3995
|
+
motion via R29 globals.css blanket override.
|
|
3996
|
+
data-node-status-ring exposes this circle for
|
|
3997
|
+
test probing — the overlap-test guard on
|
|
3998
|
+
stroke-width="3"/"1.5" still works against the
|
|
3999
|
+
DOM attribute value (React-rendered, not
|
|
4000
|
+
interpolated). */}
|
|
1267
4001
|
<circle
|
|
1268
4002
|
cx={pos.x}
|
|
1269
4003
|
cy={pos.y}
|
|
@@ -1273,7 +4007,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1273
4007
|
strokeWidth={isOnline ? 3 : 1.5}
|
|
1274
4008
|
strokeDasharray={isOnline ? 'none' : '5 5'}
|
|
1275
4009
|
filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
|
|
1276
|
-
|
|
4010
|
+
data-node-status-ring={status.label}
|
|
4011
|
+
style={{
|
|
4012
|
+
transition: 'fill 300ms ease-out, stroke 300ms ease-out, stroke-width 300ms ease-out',
|
|
4013
|
+
}}
|
|
1277
4014
|
/>
|
|
1278
4015
|
{/* Issue #96: node "avatar" is now driven by the model
|
|
1279
4016
|
vendor. Decision order:
|
|
@@ -1360,52 +4097,159 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1360
4097
|
</g>
|
|
1361
4098
|
);
|
|
1362
4099
|
})()}
|
|
1363
|
-
{session.status === 'working' && (
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
4100
|
+
{session.status === 'working' && (() => {
|
|
4101
|
+
// Round 24 / Loop: pulse rate ↔ traffic. The dot's dur
|
|
4102
|
+
// was a flat 1.1s — every working node looked equally
|
|
4103
|
+
// busy. Mapping it to sse:N lets the eye land on what's
|
|
4104
|
+
// actually hot. 3 discrete tiers read cleaner than
|
|
4105
|
+
// continuous easing at a glance; thresholds picked so
|
|
4106
|
+
// typical 1-2-SSE Claude/Codex sessions stay in the
|
|
4107
|
+
// "active" tier and only multi-pane orchestrators pop
|
|
4108
|
+
// into the "busy" tier. Geometry unchanged (r=2.5).
|
|
4109
|
+
const sse = sseCountFor ?? 0;
|
|
4110
|
+
const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
|
|
4111
|
+
return (
|
|
4112
|
+
<circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
|
|
4113
|
+
{!reducedMotion && (
|
|
4114
|
+
<animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
|
|
4115
|
+
)}
|
|
4116
|
+
</circle>
|
|
4117
|
+
);
|
|
4118
|
+
})()}
|
|
1372
4119
|
|
|
1373
4120
|
{/* Round 98 (issue #61): label rect 124px → 100px.
|
|
1374
4121
|
Round 109/110 (Vincent P0): full opaque card below the
|
|
1375
4122
|
density threshold / on hover / when zoomed; otherwise a
|
|
1376
4123
|
lightweight plain-text alias that keeps every node
|
|
1377
|
-
labelled without an opaque box covering its neighbours.
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
4124
|
+
labelled without an opaque box covering its neighbours.
|
|
4125
|
+
Round 15 / Loop: when nodeScale=S the node shrinks 30%
|
|
4126
|
+
but the label card was staying full-size, so the label
|
|
4127
|
+
visually outweighed the small node. Tighten the card
|
|
4128
|
+
frame, alias / sub fontSize, drop-offset and truncate
|
|
4129
|
+
length specifically for S; M and L keep their existing
|
|
4130
|
+
sizes (M ≈ L for labels by design — the S user is the
|
|
4131
|
+
one who explicitly asked for a denser view). */}
|
|
4132
|
+
{(() => {
|
|
4133
|
+
const isSmall = nodeScale < 0.8;
|
|
4134
|
+
const cardW = isSmall ? 88 : 100;
|
|
4135
|
+
const cardH = isSmall ? 36 : 42;
|
|
4136
|
+
const cardTopY = isSmall ? -12 : -14;
|
|
4137
|
+
const aliasFs = isSmall ? 11 : 12;
|
|
4138
|
+
const subFs = isSmall ? 8 : 9;
|
|
4139
|
+
const subY = isSmall ? 15 : 17;
|
|
4140
|
+
const dropY = isSmall ? 18 : 22;
|
|
4141
|
+
const fullMax = isSmall ? 11 : 12;
|
|
4142
|
+
const denseFs = isSmall ? 9 : 10;
|
|
4143
|
+
const denseDrop = isSmall ? 12 : 14;
|
|
4144
|
+
// Round 26 / Loop: micro-lift the label group on hover —
|
|
4145
|
+
// 1.5 px upward, 200 ms ease. Pairs with the Round 18
|
|
4146
|
+
// group-box hover-accent treatment to give the same
|
|
4147
|
+
// "this is the focused element" feedback at the per-
|
|
4148
|
+
// node level. CSS transform stacks onto the SVG
|
|
4149
|
+
// positioning transform attribute (SVG 2 cascade);
|
|
4150
|
+
// bbox is unchanged at rest, so the overlap-test gate
|
|
4151
|
+
// continues to see the geometric layout it expects.
|
|
4152
|
+
return showFullLabel ? (
|
|
4153
|
+
<g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
|
|
4154
|
+
className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
|
|
4155
|
+
{/* Round 194 / Loop: label card picks up a subtle
|
|
4156
|
+
drop-shadow that intensifies when the node is
|
|
4157
|
+
hovered — pairs the existing R26 1.5px lift
|
|
4158
|
+
with physical weight. Pre-R194 the card lifted
|
|
4159
|
+
1.5 px on hover but had no shadow follow, so
|
|
4160
|
+
the gesture read as "card translated" rather
|
|
4161
|
+
than "card rose off the canvas". Adding a
|
|
4162
|
+
baseline shadow at rest (1px/2px blur) plus a
|
|
4163
|
+
deeper hover state (3-4px/8-12px blur) makes
|
|
4164
|
+
the elevation feel earned.
|
|
4165
|
+
Same R57/R135 hover-elevation idiom that
|
|
4166
|
+
panels already use, now extended to per-node
|
|
4167
|
+
label cards. data-node-label-card-elevation
|
|
4168
|
+
('idle'/'hover') exposes the state for tests.
|
|
4169
|
+
Filter is theme-aware (light: slate alpha;
|
|
4170
|
+
dark: black alpha) so the shadow stays visible
|
|
4171
|
+
against both surfaces. transition: filter
|
|
4172
|
+
220ms ease-out matches R135's panel-elevation
|
|
4173
|
+
duration so a hovered node + a hovered panel
|
|
4174
|
+
fade at the same rhythm. Reduced-motion users
|
|
4175
|
+
collapse to the rest-state shadow only — no
|
|
4176
|
+
hover differentiation. Per-node filter is
|
|
4177
|
+
gated to showFullLabel which itself is gated
|
|
4178
|
+
to non-dense fleets (≤16 nodes) or hovered/
|
|
4179
|
+
zoomed-in branches, so the cost stays bounded
|
|
4180
|
+
(~20-30 cards max). */}
|
|
4181
|
+
<rect
|
|
4182
|
+
x={-cardW / 2} y={cardTopY} width={cardW} height={cardH} rx="6"
|
|
4183
|
+
fill={pal.labelBox.fill} stroke={pal.labelBox.stroke}
|
|
4184
|
+
opacity={isLight ? 1 : 0.94}
|
|
4185
|
+
data-node-label-card={session.alias}
|
|
4186
|
+
data-node-label-card-elevation={
|
|
4187
|
+
!reducedMotion && hoveredAlias === session.alias ? 'hover' : 'idle'
|
|
4188
|
+
}
|
|
4189
|
+
style={{
|
|
4190
|
+
filter: !reducedMotion && hoveredAlias === session.alias
|
|
4191
|
+
? (isLight
|
|
4192
|
+
? 'drop-shadow(0 3px 8px rgba(15,23,42,0.20))'
|
|
4193
|
+
: 'drop-shadow(0 4px 12px rgba(0,0,0,0.60))')
|
|
4194
|
+
: (isLight
|
|
4195
|
+
? 'drop-shadow(0 1px 2px rgba(15,23,42,0.08))'
|
|
4196
|
+
: 'drop-shadow(0 1px 2px rgba(0,0,0,0.30))'),
|
|
4197
|
+
transition: 'filter 220ms ease-out',
|
|
4198
|
+
}}
|
|
4199
|
+
/>
|
|
4200
|
+
<text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
|
|
4201
|
+
{truncate(session.alias, fullMax)}
|
|
4202
|
+
</text>
|
|
4203
|
+
<text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
|
|
4204
|
+
{status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
|
|
4205
|
+
</text>
|
|
4206
|
+
</g>
|
|
4207
|
+
) : (
|
|
4208
|
+
<text
|
|
4209
|
+
x={pos.x}
|
|
4210
|
+
y={pos.y + radius + denseDrop}
|
|
4211
|
+
textAnchor="middle"
|
|
4212
|
+
fill={status.text}
|
|
4213
|
+
fontSize={denseFs}
|
|
4214
|
+
fontFamily="monospace"
|
|
4215
|
+
fontWeight="700"
|
|
4216
|
+
opacity={0.9}
|
|
4217
|
+
className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
|
|
4218
|
+
style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
|
|
4219
|
+
stroke={pal.containerBg}
|
|
4220
|
+
strokeWidth="3"
|
|
4221
|
+
>
|
|
4222
|
+
{truncate(session.alias, isSmall ? 9 : 10)}
|
|
1386
4223
|
</text>
|
|
1387
|
-
|
|
1388
|
-
)
|
|
1389
|
-
<text
|
|
1390
|
-
x={pos.x}
|
|
1391
|
-
y={pos.y + radius + 14}
|
|
1392
|
-
textAnchor="middle"
|
|
1393
|
-
fill={status.text}
|
|
1394
|
-
fontSize="10"
|
|
1395
|
-
fontFamily="monospace"
|
|
1396
|
-
fontWeight="700"
|
|
1397
|
-
opacity={0.9}
|
|
1398
|
-
style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
|
|
1399
|
-
stroke={pal.containerBg}
|
|
1400
|
-
strokeWidth="3"
|
|
1401
|
-
>
|
|
1402
|
-
{truncate(session.alias, 10)}
|
|
1403
|
-
</text>
|
|
1404
|
-
)}
|
|
4224
|
+
);
|
|
4225
|
+
})()}
|
|
1405
4226
|
</g>
|
|
1406
4227
|
);
|
|
1407
4228
|
})}
|
|
1408
4229
|
|
|
4230
|
+
{/* Round 14 / Loop: click ripple — one-shot expanding ring from
|
|
4231
|
+
the clicked node, ~500ms, status-coloured. Sits inside the
|
|
4232
|
+
zoom/pan <g> so it scales / pans with the topology. Keyed by
|
|
4233
|
+
ts so a re-click on any node (same or different) remounts the
|
|
4234
|
+
<circle> and the SMIL <animate> elements replay from t=0.
|
|
4235
|
+
strokeWidth=2 doesn't match the overlap-test selectors. */}
|
|
4236
|
+
{clickRipple && !reducedMotion && (
|
|
4237
|
+
<circle
|
|
4238
|
+
key={clickRipple.ts}
|
|
4239
|
+
cx={clickRipple.x}
|
|
4240
|
+
cy={clickRipple.y}
|
|
4241
|
+
r={clickRipple.r0 + 4}
|
|
4242
|
+
fill="none"
|
|
4243
|
+
stroke={clickRipple.color}
|
|
4244
|
+
strokeWidth="2"
|
|
4245
|
+
opacity="0"
|
|
4246
|
+
style={{ pointerEvents: 'none' }}
|
|
4247
|
+
>
|
|
4248
|
+
<animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
|
|
4249
|
+
<animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
|
|
4250
|
+
</circle>
|
|
4251
|
+
)}
|
|
4252
|
+
|
|
1409
4253
|
</g>
|
|
1410
4254
|
|
|
1411
4255
|
{/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
|
|
@@ -1416,38 +4260,813 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1416
4260
|
corner triangles, so the panels never overlap a node, in ring
|
|
1417
4261
|
OR grid layout (Vincent 4727 zero-overlap criterion). */}
|
|
1418
4262
|
{/* latest flow labels */}
|
|
1419
|
-
|
|
1420
|
-
|
|
4263
|
+
{/* Round 57 / Loop: drop-shadow on the panel rects gives them
|
|
4264
|
+
card-like elevation, especially on light theme where the
|
|
4265
|
+
near-white fill on a near-white canvas read as pasted-on.
|
|
4266
|
+
data-topo-panel-elevation tag so the test can verify both
|
|
4267
|
+
panels carry the filter. The filter is on the rect, not
|
|
4268
|
+
the parent <g>, so it doesn't shadow the rows + text inside
|
|
4269
|
+
— only the panel chrome lifts. */}
|
|
4270
|
+
<g
|
|
4271
|
+
transform="translate(16, 16)"
|
|
4272
|
+
data-topo-panel="recent"
|
|
4273
|
+
data-topo-panel-hovered={hoveredPanel === 'recent' ? 'true' : 'false'}
|
|
4274
|
+
// Round 175 / Loop: corner panels fade-in after the
|
|
4275
|
+
// R9/R72/R172/R173/R174 canvas content reveal. Pre-R175
|
|
4276
|
+
// recent-signal + legend panels appeared instantly in
|
|
4277
|
+
// the corners while nodes/edges/group boxes staggered
|
|
4278
|
+
// in around them — felt like 'panels are already
|
|
4279
|
+
// there, content shows up'. Delaying the panels to
|
|
4280
|
+
// ~700ms (after the first node wave finishes ~540ms
|
|
4281
|
+
// and edges begin filling in ~280ms) makes them drop
|
|
4282
|
+
// into place AFTER the canvas has revealed.
|
|
4283
|
+
// recent-signal panel at 700ms; legend at 800ms below
|
|
4284
|
+
// for a soft left-then-right cascade. .anet-fade-in
|
|
4285
|
+
// is the same R3 mount-once animation the other 4
|
|
4286
|
+
// wave layers use — fifth surface in the family.
|
|
4287
|
+
className="anet-fade-in"
|
|
4288
|
+
data-topo-panel-fade-delay={700}
|
|
4289
|
+
style={{ animationDelay: '700ms' }}
|
|
4290
|
+
onMouseEnter={() => setHoveredPanel('recent')}
|
|
4291
|
+
onMouseLeave={() => setHoveredPanel(prev => prev === 'recent' ? null : prev)}
|
|
4292
|
+
>
|
|
4293
|
+
<rect
|
|
4294
|
+
x="0" y="0" width="230" height="84" rx="10"
|
|
4295
|
+
fill={pal.legendBox.fill}
|
|
4296
|
+
stroke={pal.legendBox.stroke}
|
|
4297
|
+
opacity={isLight ? 0.97 : 0.92}
|
|
4298
|
+
style={{
|
|
4299
|
+
/* R135: drop-shadow intensifies on panel hover. Base
|
|
4300
|
+
shadow (2px / 6px blur) signals card elevation
|
|
4301
|
+
(R57); hovered (4px / 12px blur) tells the user
|
|
4302
|
+
the whole chrome is interactive territory — rows
|
|
4303
|
+
pin (R116), footer navigates (R133), legend rows
|
|
4304
|
+
pin status (R61). Reuses R18's KPI-card hover-
|
|
4305
|
+
elevation idiom for visual consistency. Theme-
|
|
4306
|
+
aware shadow colour stays the same; just the
|
|
4307
|
+
spread + blur grow. */
|
|
4308
|
+
filter: hoveredPanel === 'recent'
|
|
4309
|
+
? (isLight ? 'drop-shadow(0 4px 12px rgba(15,23,42,0.14))'
|
|
4310
|
+
: 'drop-shadow(0 4px 12px rgba(0,0,0,0.65))')
|
|
4311
|
+
: (isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))'
|
|
4312
|
+
: 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))'),
|
|
4313
|
+
transition: 'filter 200ms ease-out',
|
|
4314
|
+
}}
|
|
4315
|
+
data-topo-panel-elevation="recent"
|
|
4316
|
+
/>
|
|
1421
4317
|
<text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
4318
|
+
{/* R96: header count now matches what the rows show. Pre-R96
|
|
4319
|
+
this read "X msgs" off the raw messages array, but the
|
|
4320
|
+
rows below render DEDUPED flowLinks — so a fleet with 10
|
|
4321
|
+
messages aggregating to 3 pairs read "10 msgs" above
|
|
4322
|
+
only 3 rows. Misreads as "where are the other 7?".
|
|
4323
|
+
"X flows" mirrors flowLinks.length one-for-one. When
|
|
4324
|
+
flows < msgs the chip-row's "N active links · last 2s"
|
|
4325
|
+
already tells the operator about traffic volume — no
|
|
4326
|
+
duplicate metric needed here.
|
|
4327
|
+
R129 / Loop: header gains an amber " · N hot" tail
|
|
4328
|
+
when ≥ 1 flowLink has count ≥ 10. The third surface
|
|
4329
|
+
of the hot-lane convention (R126 canvas badge / R127
|
|
4330
|
+
row count) lives at the panel header so a user
|
|
4331
|
+
scanning vertically — header → rows — gets a top-
|
|
4332
|
+
level summary before reading each row's amber digit.
|
|
4333
|
+
Restructured into a single <text> with <tspan>
|
|
4334
|
+
fragments so the amber portion can carry its own
|
|
4335
|
+
fill + weight without a sibling <text>. Switched
|
|
4336
|
+
anchor x=150 left-justified → x=217 right-justified
|
|
4337
|
+
so the count column unifies visually with the legend
|
|
4338
|
+
panel's right-justified header (line 3511 — also
|
|
4339
|
+
fontSize 10 monospace, also x≈215 textAnchor end).
|
|
4340
|
+
data-recent-panel-count stays on the flow tspan so
|
|
4341
|
+
the R96 / R128 tests still resolve. data-recent-
|
|
4342
|
+
panel-hot-count exposes the hot bucket count. */}
|
|
4343
|
+
{(() => {
|
|
4344
|
+
const hotFlowCount = flowLinks.filter(l => l.count >= 10).length;
|
|
4345
|
+
const hotStroke = isLight ? '#d97706' : '#fbbf24';
|
|
4346
|
+
// R162 / Loop: freshness tint on the panel-header
|
|
4347
|
+
// count tspan. R161 just colored the chip-row's "N
|
|
4348
|
+
// active links · last 5s" bullet by recency; the
|
|
4349
|
+
// recent-signal panel header is the panel-side
|
|
4350
|
+
// mirror of the same metric (flowLinks.length). Both
|
|
4351
|
+
// scopes now speak the same freshness vocabulary,
|
|
4352
|
+
// so a glance at either tells the operator whether
|
|
4353
|
+
// the network is firing right now. Four nested
|
|
4354
|
+
// scopes share one ladder:
|
|
4355
|
+
// canvas edge fade (R10)
|
|
4356
|
+
// row pip (R160)
|
|
4357
|
+
// chip bullet (R161)
|
|
4358
|
+
// panel header (R162, this round)
|
|
4359
|
+
// Same alpha ramp:
|
|
4360
|
+
// ageSec ≤ 30 → 1.0 (fully fresh)
|
|
4361
|
+
// 30-300s → smooth decay 1.0 → 0.25
|
|
4362
|
+
// > 300s → 0.25 stale floor
|
|
4363
|
+
// Hot tail (amber " · N hot" R129) is independent
|
|
4364
|
+
// of recency and keeps its own color — recency
|
|
4365
|
+
// tints the head; volume colors the tail.
|
|
4366
|
+
const recentMs = flowLinks.reduce<number | null>((acc, l) => {
|
|
4367
|
+
if (!l.last_at) return acc;
|
|
4368
|
+
const t = Date.parse(l.last_at);
|
|
4369
|
+
if (Number.isNaN(t)) return acc;
|
|
4370
|
+
return acc === null || t > acc ? t : acc;
|
|
4371
|
+
}, null);
|
|
4372
|
+
const ageSec = recentMs !== null
|
|
4373
|
+
? Math.max(0, (Date.now() - recentMs) / 1000)
|
|
4374
|
+
: 999;
|
|
4375
|
+
const alpha = ageSec <= 30
|
|
4376
|
+
? 1
|
|
4377
|
+
: ageSec <= 300
|
|
4378
|
+
? 1 - ((ageSec - 30) / 270) * 0.75
|
|
4379
|
+
: 0.25;
|
|
4380
|
+
// Dark cyan-400 / light teal-600 with alpha — same
|
|
4381
|
+
// palette as R161's chip bullet so the two scopes
|
|
4382
|
+
// visually align even side-by-side.
|
|
4383
|
+
const freshFill = isLight
|
|
4384
|
+
? `rgba(13, 148, 136, ${alpha.toFixed(2)})`
|
|
4385
|
+
: `rgba(34, 211, 238, ${alpha.toFixed(2)})`;
|
|
4386
|
+
return (
|
|
4387
|
+
<text
|
|
4388
|
+
x="217" y="21"
|
|
4389
|
+
textAnchor="end"
|
|
4390
|
+
fontSize="10"
|
|
4391
|
+
fontFamily="monospace"
|
|
4392
|
+
>
|
|
4393
|
+
<tspan
|
|
4394
|
+
fill={freshFill}
|
|
4395
|
+
data-recent-panel-count
|
|
4396
|
+
data-recent-panel-count-freshness-alpha={alpha.toFixed(2)}
|
|
4397
|
+
style={{ transition: 'fill 200ms ease-out' }}
|
|
4398
|
+
>{flowLinks.length} flows</tspan>
|
|
4399
|
+
{/* Round 190 / Loop: R129 hot-tail gets anet-fade-in
|
|
4400
|
+
for entrance. Pre-R190 the tspan snapped into
|
|
4401
|
+
the header the moment hotFlowCount crossed 0,
|
|
4402
|
+
and snapped out the moment it dropped back to 0.
|
|
4403
|
+
Same trade-off R67 accepts for filter pills:
|
|
4404
|
+
fade-IN smooth, accept exit snap. The CSS
|
|
4405
|
+
animation plays once when the tspan mounts
|
|
4406
|
+
(count goes 0 → 1+); subsequent re-renders
|
|
4407
|
+
(count growing from 1 → 2 → 3 hot flows)
|
|
4408
|
+
preserve the element via React reconciliation
|
|
4409
|
+
so the fade-in doesn't replay. Layout-shift
|
|
4410
|
+
cost is paid once on entrance — the parent
|
|
4411
|
+
<text textAnchor="end"> recomputes its
|
|
4412
|
+
anchor as the tspan appears, then stays
|
|
4413
|
+
stable as the digit grows. Exit-snap is rare
|
|
4414
|
+
in steady operation: a hot flow cooling back
|
|
4415
|
+
below 10 messages doesn't happen often. */}
|
|
4416
|
+
{hotFlowCount > 0 && (
|
|
4417
|
+
<tspan
|
|
4418
|
+
fill={hotStroke}
|
|
4419
|
+
fontWeight="700"
|
|
4420
|
+
data-recent-panel-hot-count={hotFlowCount}
|
|
4421
|
+
className="anet-fade-in"
|
|
4422
|
+
>
|
|
4423
|
+
{' · '}{hotFlowCount} hot
|
|
4424
|
+
</tspan>
|
|
4425
|
+
)}
|
|
4426
|
+
</text>
|
|
4427
|
+
);
|
|
4428
|
+
})()}
|
|
4429
|
+
{/* Round 45 / Loop: empty state. The panel used to render
|
|
4430
|
+
"recent signal" + "0 msgs" with three blank slots below
|
|
4431
|
+
when no flow yet — read as "broken" rather than "quiet".
|
|
4432
|
+
A muted centred placeholder makes the empty state
|
|
4433
|
+
deliberate. Messages count CAN diverge from flowLinks
|
|
4434
|
+
count (raw count vs. deduped pairs), so the placeholder
|
|
4435
|
+
fires on flowLinks.length=0 specifically. */}
|
|
4436
|
+
{flowLinks.length === 0 ? (
|
|
4437
|
+
/* R45 placeholder. R110: add a sub-text hint so the
|
|
4438
|
+
empty state explains what it's empty OF — operators
|
|
4439
|
+
looking at "no flow yet" sometimes mistook it for
|
|
4440
|
+
a connection error. The sub-line reads as a quiet
|
|
4441
|
+
invitation, not an error. Two-line layout uses
|
|
4442
|
+
standard SVG <text>+<text> rather than tspan so the
|
|
4443
|
+
y-coordinates are explicit and the data-attr selector
|
|
4444
|
+
still finds the primary line. */
|
|
4445
|
+
<>
|
|
4446
|
+
<text x="115" y="54" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
|
|
4447
|
+
no flow yet
|
|
4448
|
+
</text>
|
|
4449
|
+
<text x="115" y="68" textAnchor="middle" fill={pal.legendText} fontSize="8" fontFamily="monospace" opacity={0.45} data-recent-signal-empty-hint>
|
|
4450
|
+
send a message between agents
|
|
4451
|
+
</text>
|
|
4452
|
+
</>
|
|
4453
|
+
) : (
|
|
4454
|
+
// Round 56 / Loop: each row is a navigator into the canvas.
|
|
4455
|
+
// Hover a row → set hoveredEdgeKey, which the existing R50
|
|
4456
|
+
// edge-focus + R49 endpoint-highlight ladders consume. The
|
|
4457
|
+
// matching flow edge brightens to 2× + thickens, its two
|
|
4458
|
+
// endpoint nodes stay full opacity, and every other edge +
|
|
4459
|
+
// non-endpoint node dims. Released → all restore. Wrapping
|
|
4460
|
+
// <g> + a transparent 218×14 hitbox so the cursor doesn't
|
|
4461
|
+
// have to land precisely on the truncated text.
|
|
4462
|
+
flowLinks.slice(0, 3).map((link, index) => {
|
|
4463
|
+
// Round 94 / Loop: per-row relative timestamp. The chip
|
|
4464
|
+
// row shows "last 2s" for the most recent flow overall,
|
|
4465
|
+
// but a user scanning the recent-signal panel had no way
|
|
4466
|
+
// to tell whether row 2 was 5s old or 5m old without
|
|
4467
|
+
// hovering nodes. Compact `2s` / `1m` glyph at the
|
|
4468
|
+
// right edge — same relativeAgo helper R42 uses for
|
|
4469
|
+
// the chip — pulls double duty as a recency anchor and
|
|
4470
|
+
// a sortedness hint (top row is freshest by construction).
|
|
4471
|
+
// Strip the " ago" suffix — at fontSize=8 in a 32-px
|
|
4472
|
+
// right-edge slot, every char counts. "30s ago" → "30s".
|
|
4473
|
+
const rawAt = link.last_at ? relativeAgo(link.last_at) : null;
|
|
4474
|
+
const lastAt = rawAt ? rawAt.replace(/\s+ago$/, '') : null;
|
|
4475
|
+
const isRowHovered = hoveredEdgeKey === link.key;
|
|
4476
|
+
const isRowPinned = pinnedEdgeKey === link.key;
|
|
4477
|
+
const isRowActive = isRowHovered || isRowPinned;
|
|
4478
|
+
// Round 191 / Loop: timestamp text on row's right edge
|
|
4479
|
+
// picks up the R160 row-pip freshness ramp at a
|
|
4480
|
+
// different alpha range — gives the timestamp the same
|
|
4481
|
+
// visual recency encoding the pip has on the LEFT
|
|
4482
|
+
// edge, so both ends of the row mirror each other.
|
|
4483
|
+
// ≤30s → opacity 0.85 (fresh, high contrast)
|
|
4484
|
+
// 30-300s → 0.85 → 0.30 (smooth decay)
|
|
4485
|
+
// >300s → 0.30 (stale floor)
|
|
4486
|
+
// Pre-R191 the timestamp was static 0.55 — the same
|
|
4487
|
+
// visual weight whether the message just fired or
|
|
4488
|
+
// happened 5 minutes ago. Fill stays legendText gray
|
|
4489
|
+
// (not cyan) because the left pip already carries the
|
|
4490
|
+
// cyan; the right timestamp encodes recency through
|
|
4491
|
+
// contrast against the dark canvas, not hue.
|
|
4492
|
+
const tsAlpha = !link.last_at ? 0.55 : (() => {
|
|
4493
|
+
const ageSec = Math.max(0, (Date.now() - Date.parse(link.last_at)) / 1000);
|
|
4494
|
+
return ageSec <= 30 ? 0.85
|
|
4495
|
+
: ageSec <= 300 ? 0.85 - ((ageSec - 30) / 270) * 0.55
|
|
4496
|
+
: 0.30;
|
|
4497
|
+
})();
|
|
4498
|
+
// R127: panel-side mirror of R126's canvas hot-badge.
|
|
4499
|
+
// The recent-signal row text packs `alias→alias / N /
|
|
4500
|
+
// preview` into one line, with N rendered identically
|
|
4501
|
+
// regardless of magnitude. Now that the canvas badge
|
|
4502
|
+
// tells the user "≥ 10 msgs = hot lane" via amber
|
|
4503
|
+
// stroke, the panel row needs the same affordance so
|
|
4504
|
+
// the user reading the list at a glance can spot hot
|
|
4505
|
+
// lanes without crossing to the canvas. Renders the
|
|
4506
|
+
// count digit in amber + 700-weight when isHot; the
|
|
4507
|
+
// surrounding alias text + separators stay in the
|
|
4508
|
+
// existing legendText/legendHeadline palette. Reuses
|
|
4509
|
+
// R126's hotStroke colour for visual consistency.
|
|
4510
|
+
const isHot = link.count >= 10;
|
|
4511
|
+
const hotStroke = isLight ? '#d97706' : '#fbbf24';
|
|
4512
|
+
return (
|
|
4513
|
+
<g
|
|
4514
|
+
key={link.key}
|
|
4515
|
+
data-recent-row={link.key}
|
|
4516
|
+
data-recent-row-hovered={isRowHovered ? 'true' : 'false'}
|
|
4517
|
+
data-recent-row-pinned={isRowPinned ? 'true' : 'false'}
|
|
4518
|
+
data-recent-row-hot={isHot ? 'true' : 'false'}
|
|
4519
|
+
data-recent-row-lifted={(isRowHovered || isRowPinned) ? 'true' : 'false'}
|
|
4520
|
+
className="anet-topo-svg-focus"
|
|
4521
|
+
role="button"
|
|
4522
|
+
tabIndex={0}
|
|
4523
|
+
aria-pressed={isRowPinned}
|
|
4524
|
+
// R143 / Loop: extend the R135/R142 "interactive surface
|
|
4525
|
+
// elevates" idiom down one layer to the recent-signal
|
|
4526
|
+
// panel rows. R104 already tints the row background on
|
|
4527
|
+
// hover; R143 adds a 1-px translate so the row text
|
|
4528
|
+
// visually lifts off the panel — same vocabulary R51
|
|
4529
|
+
// uses for nodes, R135 uses for panels, R142 uses for
|
|
4530
|
+
// group boxes. Pinned rows lift too (sticky state
|
|
4531
|
+
// should look like locked-in selection). Reduced-motion
|
|
4532
|
+
// safe via prefers-reduced-motion blanket override
|
|
4533
|
+
// applied to transition-duration in globals.css.
|
|
4534
|
+
style={{
|
|
4535
|
+
cursor: 'pointer',
|
|
4536
|
+
transform: (isRowHovered || isRowPinned) ? 'translateY(-1px)' : undefined,
|
|
4537
|
+
transition: 'transform 150ms cubic-bezier(0.4, 0, 0.2, 1)',
|
|
4538
|
+
}}
|
|
4539
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
4540
|
+
onMouseEnter={() => setHoveredEdgeKey(link.key)}
|
|
4541
|
+
onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
|
|
4542
|
+
// R116: click toggles pin. activeEdgeKey =
|
|
4543
|
+
// hoveredEdgeKey ?? pinnedEdgeKey so the matching
|
|
4544
|
+
// edge stays "hot" after mouseleave; click again
|
|
4545
|
+
// (or Esc) releases.
|
|
4546
|
+
onClick={() => setPinnedEdgeKey(prev => prev === link.key ? null : link.key)}
|
|
4547
|
+
onKeyDown={(e) => {
|
|
4548
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
4549
|
+
e.preventDefault();
|
|
4550
|
+
setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
|
|
4551
|
+
}
|
|
4552
|
+
}}
|
|
4553
|
+
>
|
|
4554
|
+
{/* R148 / Loop: row tooltip with full message context.
|
|
4555
|
+
The row text truncates aliases to 6 chars (R127)
|
|
4556
|
+
and content to 8 chars — useful for scan-density
|
|
4557
|
+
but obscures the underlying message. A native SVG
|
|
4558
|
+
<title> reveals the full alias / content /
|
|
4559
|
+
timestamp on hover. Pinned vs unpinned switches
|
|
4560
|
+
the click hint so the user knows the next
|
|
4561
|
+
gesture's effect. R98 enriched the node tooltip
|
|
4562
|
+
the same way for the source/destination scope;
|
|
4563
|
+
R148 brings the per-row equivalent to the panel
|
|
4564
|
+
side. */}
|
|
4565
|
+
<title>{[
|
|
4566
|
+
`${link.from} → ${link.to} · ${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ' (hot lane · ≥ 10)' : ''}`,
|
|
4567
|
+
link.last_at ? `last: ${new Date(link.last_at).toLocaleString()}` : null,
|
|
4568
|
+
link.content ? `"${link.content}"` : null,
|
|
4569
|
+
isRowPinned ? 'click to release pin (Esc to clear)' : 'click to pin · hover to preview',
|
|
4570
|
+
].filter(Boolean).join('\n')}</title>
|
|
4571
|
+
{/* R104: subtle row-background tint on hover. R56
|
|
4572
|
+
already brightens the matching edge on the canvas,
|
|
4573
|
+
but the panel row itself stayed flat — felt more
|
|
4574
|
+
like text-with-handlers than a navigable list.
|
|
4575
|
+
Filling the rect at hover with `pal.legendAccent`
|
|
4576
|
+
at low alpha gives the row visual feedback at the
|
|
4577
|
+
source surface, mirroring the list-item idiom from
|
|
4578
|
+
the chip-row pills. R116: pinned rows tint
|
|
4579
|
+
stronger than hovered ones so locked vs preview
|
|
4580
|
+
is discriminable. */}
|
|
4581
|
+
<rect
|
|
4582
|
+
x="6" y={38 + index * 16 - 10}
|
|
4583
|
+
width="218" height="14" rx="3"
|
|
4584
|
+
fill={isRowActive ? pal.legendAccent : 'transparent'}
|
|
4585
|
+
opacity={isRowPinned ? (isLight ? 0.18 : 0.22)
|
|
4586
|
+
: isRowHovered ? (isLight ? 0.10 : 0.14)
|
|
4587
|
+
: 1}
|
|
4588
|
+
style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
|
|
4589
|
+
/>
|
|
4590
|
+
{/* Round 160 / Loop: recency pip. Canvas flow edges
|
|
4591
|
+
fade by freshness (R10: full intensity ≤30s →
|
|
4592
|
+
~35% over 5min). The recent-signal panel rows
|
|
4593
|
+
duplicate that data (alias→alias · N · 5s)
|
|
4594
|
+
but encode freshness purely in text — no
|
|
4595
|
+
at-a-glance visual cue for "which row is
|
|
4596
|
+
actively firing right now". A 1.6-px cyan dot
|
|
4597
|
+
at x=10 (in the 7-px margin between rect-
|
|
4598
|
+
start x=6 and text-start x=13) brightens
|
|
4599
|
+
fresh rows and dims stale ones — same
|
|
4600
|
+
vocabulary the canvas uses, brought to the
|
|
4601
|
+
panel side.
|
|
4602
|
+
|
|
4603
|
+
Three encodings now coexist on each row,
|
|
4604
|
+
none competing:
|
|
4605
|
+
rect fill = hover/pin state (R104/R116)
|
|
4606
|
+
count tspan = magnitude (R127 amber when ≥10)
|
|
4607
|
+
pip = recency (this round)
|
|
4608
|
+
|
|
4609
|
+
Geometry: cy = row_y - 3 (mid-row vertical
|
|
4610
|
+
centre, where text baseline at y=row_y sits
|
|
4611
|
+
slightly below). r=1.6 fits cleanly in the
|
|
4612
|
+
7-px left margin. pointerEvents:none so the
|
|
4613
|
+
row's button-role hit area is unchanged.
|
|
4614
|
+
No overlap-test impact (entirely within the
|
|
4615
|
+
existing rect bbox). */}
|
|
4616
|
+
{(() => {
|
|
4617
|
+
if (!link.last_at) return null;
|
|
4618
|
+
const ageSec = Math.max(0, (Date.now() - Date.parse(link.last_at)) / 1000);
|
|
4619
|
+
// 0-30s: fully fresh (1.0). 30-300s: smooth
|
|
4620
|
+
// decay 1→0.25. >300s: stale floor (0.25).
|
|
4621
|
+
const alpha = ageSec <= 30
|
|
4622
|
+
? 1
|
|
4623
|
+
: ageSec <= 300
|
|
4624
|
+
? 1 - ((ageSec - 30) / 270) * 0.75
|
|
4625
|
+
: 0.25;
|
|
4626
|
+
return (
|
|
4627
|
+
<circle
|
|
4628
|
+
cx={10}
|
|
4629
|
+
cy={38 + index * 16 - 3}
|
|
4630
|
+
r={1.6}
|
|
4631
|
+
fill={pal.legendAccent}
|
|
4632
|
+
opacity={alpha}
|
|
4633
|
+
data-recent-row-freshness={link.key}
|
|
4634
|
+
data-recent-row-freshness-alpha={alpha.toFixed(2)}
|
|
4635
|
+
style={{ pointerEvents: 'none', transition: 'opacity 200ms ease-out' }}
|
|
4636
|
+
/>
|
|
4637
|
+
);
|
|
4638
|
+
})()}
|
|
4639
|
+
<text
|
|
4640
|
+
x="13" y={38 + index * 16}
|
|
4641
|
+
fill={isRowActive ? pal.legendHeadline : pal.legendText}
|
|
4642
|
+
fontSize="9"
|
|
4643
|
+
fontFamily="monospace"
|
|
4644
|
+
style={{ transition: 'fill 150ms ease-out' }}
|
|
4645
|
+
>
|
|
4646
|
+
{/* R138 / Loop: typography unification with the rest
|
|
4647
|
+
of the topology UI. Filter pills (R119) render
|
|
4648
|
+
"{from}→{to}", node tooltips (R98) use →, the
|
|
4649
|
+
active-links chip tooltip (R114) and edge-badge
|
|
4650
|
+
titles all use unicode →. The recent-signal row
|
|
4651
|
+
was the lone holdout still rendering "from -> to"
|
|
4652
|
+
in ASCII. The data delimiter likewise: filter
|
|
4653
|
+
pills use " · " ("status · 3"); the row was using
|
|
4654
|
+
" / ". Both swaps make the row read like every
|
|
4655
|
+
other surface — one less micro-style to remember. */}
|
|
4656
|
+
{truncate(link.from, 6)} {'→'} {truncate(link.to, 6)} {' · '}
|
|
4657
|
+
{/* Round 189 / Loop: count tspan unified — pre-R189
|
|
4658
|
+
two different tspans (data-recent-row-count vs
|
|
4659
|
+
data-recent-row-count-hot) mounted/unmounted
|
|
4660
|
+
on the R127 hot threshold (count >= 10),
|
|
4661
|
+
making fill (legendText ↔ amber) + fontWeight
|
|
4662
|
+
(regular ↔ 700) snap one-frame. Now one tspan
|
|
4663
|
+
always-mounted; isHot drives fill/fontWeight
|
|
4664
|
+
conditionally. style.transition='fill 300ms
|
|
4665
|
+
ease-out' makes the hot crossing ease through
|
|
4666
|
+
the colour shift — same vocabulary R188 just
|
|
4667
|
+
added to the edge midpoint badge stroke (the
|
|
4668
|
+
panel-side mirror of that surface). fontWeight
|
|
4669
|
+
stays binary (no clean weight interpolation
|
|
4670
|
+
across browsers). data-recent-row-count
|
|
4671
|
+
continues to expose the tspan to existing
|
|
4672
|
+
tests; data-recent-row-count-hot becomes
|
|
4673
|
+
an attribute on the same element when active
|
|
4674
|
+
so legacy probes still resolve. */}
|
|
4675
|
+
<tspan
|
|
4676
|
+
fill={isHot ? hotStroke : undefined}
|
|
4677
|
+
fontWeight={isHot ? '700' : undefined}
|
|
4678
|
+
data-recent-row-count
|
|
4679
|
+
{...(isHot ? { 'data-recent-row-count-hot': 'true' } : {})}
|
|
4680
|
+
style={{ transition: 'fill 300ms ease-out' }}
|
|
4681
|
+
>
|
|
4682
|
+
{link.count}
|
|
4683
|
+
</tspan>
|
|
4684
|
+
{' · '}{truncate(link.content, 8)}
|
|
4685
|
+
</text>
|
|
4686
|
+
{lastAt ? (
|
|
4687
|
+
<text
|
|
4688
|
+
x="217" y={38 + index * 16}
|
|
4689
|
+
textAnchor="end"
|
|
4690
|
+
fill={pal.legendText}
|
|
4691
|
+
fontSize="8"
|
|
4692
|
+
fontFamily="monospace"
|
|
4693
|
+
opacity={tsAlpha}
|
|
4694
|
+
data-recent-row-ts={link.key}
|
|
4695
|
+
data-recent-row-ts-alpha={tsAlpha.toFixed(2)}
|
|
4696
|
+
style={{ pointerEvents: 'none', transition: 'opacity 200ms ease-out' }}
|
|
4697
|
+
>
|
|
4698
|
+
{lastAt}
|
|
4699
|
+
</text>
|
|
4700
|
+
) : null}
|
|
4701
|
+
</g>
|
|
4702
|
+
);
|
|
4703
|
+
})
|
|
4704
|
+
)}
|
|
4705
|
+
{/* Round 128 / Loop: overflow hint. The recent-signal panel
|
|
4706
|
+
renders the top 3 flowLinks via .slice(0, 3) — but a
|
|
4707
|
+
fleet with 5 or 10 active flows silently truncates the
|
|
4708
|
+
rest. The R96 "X flows" header tells the total but
|
|
4709
|
+
doesn't say "you're seeing top-3". This hint fires
|
|
4710
|
+
only when flowLinks.length > 3 so quiet fleets stay
|
|
4711
|
+
clean. Footer y=82 sits in the 10-px gap between
|
|
4712
|
+
row 3 (baseline 70) and the panel bottom (84), so
|
|
4713
|
+
overlap-test geometry is unchanged. fontStyle=italic
|
|
4714
|
+
+ opacity 0.55 reads as muted metadata, not an
|
|
4715
|
+
actionable row — matches the R110 empty-state hint
|
|
4716
|
+
idiom. */}
|
|
4717
|
+
{flowLinks.length > 3 && (() => {
|
|
4718
|
+
const moreCount = flowLinks.length - 3;
|
|
4719
|
+
const label = `+ ${moreCount} more flow${moreCount === 1 ? '' : 's'}`;
|
|
4720
|
+
// R133: the truncation hint becomes a clickable nav to
|
|
4721
|
+
// /messages. R128 introduced the footer as pure metadata
|
|
4722
|
+
// ("you're seeing top-3"); R133 closes the gap by giving
|
|
4723
|
+
// users a way to ACT on that info — see the full list.
|
|
4724
|
+
// Wrap in <g> with onClick so SVG hit-testing fires the
|
|
4725
|
+
// route push. cursor:pointer + the underline-on-hover
|
|
4726
|
+
// visual cue tells users this is interactive. The hover
|
|
4727
|
+
// state is React-controlled (no CSS :hover on SVG <g>
|
|
4728
|
+
// descendant text would feel reliable across Chrome's
|
|
4729
|
+
// SVG quirks). pointerEvents:all so the text receives
|
|
4730
|
+
// the click; onPointerDown stop-propagation so the
|
|
4731
|
+
// R103 zoom/pan capture doesn't intercept first.
|
|
4732
|
+
return (
|
|
4733
|
+
<g
|
|
4734
|
+
data-recent-panel-more-nav
|
|
4735
|
+
role="link"
|
|
4736
|
+
tabIndex={0}
|
|
4737
|
+
className="anet-topo-svg-focus"
|
|
4738
|
+
style={{ cursor: 'pointer', pointerEvents: 'all' }}
|
|
4739
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
4740
|
+
onMouseEnter={() => setHoveredRecentMore(true)}
|
|
4741
|
+
onMouseLeave={() => setHoveredRecentMore(false)}
|
|
4742
|
+
onClick={() => router.push('/messages')}
|
|
4743
|
+
onKeyDown={(e) => {
|
|
4744
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
4745
|
+
e.preventDefault();
|
|
4746
|
+
router.push('/messages');
|
|
4747
|
+
}
|
|
4748
|
+
}}
|
|
4749
|
+
>
|
|
4750
|
+
<title>{`${label} — open /messages for the full list`}</title>
|
|
4751
|
+
<text
|
|
4752
|
+
x="115" y="82"
|
|
4753
|
+
textAnchor="middle"
|
|
4754
|
+
fill={pal.legendText}
|
|
4755
|
+
fontSize="8"
|
|
4756
|
+
fontFamily="monospace"
|
|
4757
|
+
fontStyle="italic"
|
|
4758
|
+
opacity={hoveredRecentMore ? 0.85 : 0.55}
|
|
4759
|
+
textDecoration={hoveredRecentMore ? 'underline' : 'none'}
|
|
4760
|
+
data-recent-panel-more={moreCount}
|
|
4761
|
+
style={{ transition: 'opacity 150ms ease-out' }}
|
|
4762
|
+
>
|
|
4763
|
+
{label}
|
|
4764
|
+
</text>
|
|
4765
|
+
</g>
|
|
4766
|
+
);
|
|
4767
|
+
})()}
|
|
1428
4768
|
</g>
|
|
1429
4769
|
|
|
1430
|
-
{/* legend
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
4770
|
+
{/* legend — Round 55 / Loop: each status row is now a hover
|
|
4771
|
+
target. Pointer enter sets `hoveredStatus`; pointer leave
|
|
4772
|
+
clears it. Node opacity formula composes the match below.
|
|
4773
|
+
The row text brightens to legendHeadline while hovered as
|
|
4774
|
+
a small affordance hint. Geometry unchanged — the new
|
|
4775
|
+
<g> wrappers only carry pointer handlers. */}
|
|
4776
|
+
<g
|
|
4777
|
+
transform="translate(760, 16)"
|
|
4778
|
+
data-topo-panel="legend"
|
|
4779
|
+
data-topo-panel-hovered={hoveredPanel === 'legend' ? 'true' : 'false'}
|
|
4780
|
+
// R175 / Loop: legend panel offset 100ms behind the
|
|
4781
|
+
// recent-signal panel so the two corner panels cascade
|
|
4782
|
+
// left-then-right rather than appearing in lockstep.
|
|
4783
|
+
// Same .anet-fade-in mechanism the four wave layers use.
|
|
4784
|
+
className="anet-fade-in"
|
|
4785
|
+
data-topo-panel-fade-delay={800}
|
|
4786
|
+
style={{ animationDelay: '800ms' }}
|
|
4787
|
+
onMouseEnter={() => setHoveredPanel('legend')}
|
|
4788
|
+
onMouseLeave={() => setHoveredPanel(prev => prev === 'legend' ? null : prev)}
|
|
4789
|
+
>
|
|
4790
|
+
{/* R57: matching drop-shadow elevation to the legend panel.
|
|
4791
|
+
R106: panel height grew 96 → 104 to seat the new header
|
|
4792
|
+
line + 4 px row-shift below it (so the new header text
|
|
4793
|
+
doesn't overlap the row-1 hitbox region).
|
|
4794
|
+
R135: hover-elevation mirrors the recent-signal panel
|
|
4795
|
+
rect at line ~3299. Both panels grow their shadow on
|
|
4796
|
+
hover to telegraph "the chrome is interactive" since
|
|
4797
|
+
their rows pin / nav. */}
|
|
4798
|
+
<rect
|
|
4799
|
+
x="0" y="0" width="224" height="104" rx="10"
|
|
4800
|
+
fill={pal.legendBox.fill}
|
|
4801
|
+
stroke={pal.legendBox.stroke}
|
|
4802
|
+
opacity={isLight ? 0.97 : 0.92}
|
|
4803
|
+
style={{
|
|
4804
|
+
filter: hoveredPanel === 'legend'
|
|
4805
|
+
? (isLight ? 'drop-shadow(0 4px 12px rgba(15,23,42,0.14))'
|
|
4806
|
+
: 'drop-shadow(0 4px 12px rgba(0,0,0,0.65))')
|
|
4807
|
+
: (isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))'
|
|
4808
|
+
: 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))'),
|
|
4809
|
+
transition: 'filter 200ms ease-out',
|
|
4810
|
+
}}
|
|
4811
|
+
data-topo-panel-elevation="legend"
|
|
4812
|
+
/>
|
|
4813
|
+
{/* R106 / Loop: panel header — symmetric with the recent-
|
|
4814
|
+
signal panel's "recent signal · N flows" (R96). Same
|
|
4815
|
+
font + position vocabulary so the two side panels feel
|
|
4816
|
+
paired. Title text at x=13 y=21; total fleet count
|
|
4817
|
+
right-aligned at x=215 y=21 in the accent colour. */}
|
|
4818
|
+
<text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">legend</text>
|
|
4819
|
+
<text x="215" y="21" textAnchor="end" fill={pal.legendAccent} fontSize="10" fontFamily="monospace" data-legend-panel-count>{sessions.length} node{sessions.length === 1 ? '' : 's'}</text>
|
|
4820
|
+
{(() => {
|
|
4821
|
+
const idleCount = onlineNodes.length - workingCount;
|
|
4822
|
+
// R106: rows shift +8 px (was y0=24, 48, 72 → 32, 56, 80)
|
|
4823
|
+
// to clear the new header row. R57 panel rect grew 96 →
|
|
4824
|
+
// 104 to seat them.
|
|
4825
|
+
const rows = [
|
|
4826
|
+
{ key: 'working' as const, y0: 32, y1: 36, fill: isLight ? '#059669' : '#22c55e', label: 'working node', count: workingCount },
|
|
4827
|
+
{ key: 'idle' as const, y0: 56, y1: 60, fill: isLight ? '#0d9488' : '#2dd4bf', label: 'online idle', count: idleCount },
|
|
4828
|
+
{ key: 'offline' as const, y0: 80, y1: 84, fill: isLight ? '#94a3b8' : '#6b7280', label: 'offline / no SSE', count: offlineNodes.length },
|
|
4829
|
+
];
|
|
4830
|
+
return rows;
|
|
4831
|
+
})().map(row => {
|
|
4832
|
+
// Round 61 / Loop: legend rows pin too — symmetric with the
|
|
4833
|
+
// R60 pressure-bar segments. R55 hover stays transient; the
|
|
4834
|
+
// new onClick toggles pinnedStatus so users can lock a
|
|
4835
|
+
// filter without holding the cursor still. Pinned row gets
|
|
4836
|
+
// an inset ring on the swatch (same vocab as R60).
|
|
4837
|
+
const isPinned = pinnedStatus === row.key;
|
|
4838
|
+
const isRowHovered = hoveredStatus === row.key;
|
|
4839
|
+
const isLifted = isRowHovered || isPinned;
|
|
4840
|
+
return (
|
|
4841
|
+
<g
|
|
4842
|
+
key={row.key}
|
|
4843
|
+
data-legend-status={row.key}
|
|
4844
|
+
data-legend-row-lifted={isLifted ? 'true' : 'false'}
|
|
4845
|
+
className="anet-topo-svg-focus"
|
|
4846
|
+
role="button"
|
|
4847
|
+
tabIndex={0}
|
|
4848
|
+
aria-pressed={isPinned}
|
|
4849
|
+
// R144: mirror of R143 — extend the row-lift idiom to the
|
|
4850
|
+
// legend panel rows for symmetry with the recent-signal
|
|
4851
|
+
// panel rows. Same translate-1px transform, same 150ms
|
|
4852
|
+
// cubic-bezier timing. R55 hovers the row (transient),
|
|
4853
|
+
// R61 pins it (sticky); both states earn the lift.
|
|
4854
|
+
// Composes with R105 row-bg-tint and R135 panel hover-
|
|
4855
|
+
// shadow → three layers of feedback at three nested
|
|
4856
|
+
// scopes (panel chrome → row text lift → bg tint).
|
|
4857
|
+
style={{
|
|
4858
|
+
cursor: 'pointer',
|
|
4859
|
+
transform: isLifted ? 'translateY(-1px)' : undefined,
|
|
4860
|
+
transition: 'transform 150ms cubic-bezier(0.4, 0, 0.2, 1)',
|
|
4861
|
+
}}
|
|
4862
|
+
// R61: stopPropagation on pointerdown so the SVG-level
|
|
4863
|
+
// pan handler (R103) doesn't setPointerCapture and
|
|
4864
|
+
// redirect the follow-up click away from this <g>.
|
|
4865
|
+
// Same trick the node <g> uses (and R52 hub).
|
|
4866
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
4867
|
+
onMouseEnter={() => setHoveredStatus(row.key)}
|
|
4868
|
+
onMouseLeave={() => setHoveredStatus(prev => prev === row.key ? null : prev)}
|
|
4869
|
+
onClick={() => setPinnedStatus(prev => prev === row.key ? null : row.key)}
|
|
4870
|
+
>
|
|
4871
|
+
{/* R149 / Loop: legend row gains a <title> tooltip
|
|
4872
|
+
symmetric with R148's recent-signal row tooltip.
|
|
4873
|
+
Same R97 idiom — anywhere the UI shows "N" should
|
|
4874
|
+
hover-explain WHICH N — applied to the legend
|
|
4875
|
+
panel which had been showing only the bucket
|
|
4876
|
+
count without alias context. Header line names
|
|
4877
|
+
the status bucket; body lists the matched aliases
|
|
4878
|
+
with 8-truncate + "+N more"; footer hint flips
|
|
4879
|
+
with pin state. Hot-lane convention doesn't apply
|
|
4880
|
+
here (status buckets aren't traffic counts), so
|
|
4881
|
+
no isHot suffix. */}
|
|
4882
|
+
<title>{(() => {
|
|
4883
|
+
const aliases = row.key === 'working'
|
|
4884
|
+
? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
|
|
4885
|
+
: row.key === 'idle'
|
|
4886
|
+
? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
|
|
4887
|
+
: offlineNodes.map(s => s.alias);
|
|
4888
|
+
const preview = aliases.slice(0, 8).join(', ');
|
|
4889
|
+
const suffix = aliases.length > 8 ? ` + ${aliases.length - 8} more` : '';
|
|
4890
|
+
return [
|
|
4891
|
+
`${row.label} · ${row.count}`,
|
|
4892
|
+
aliases.length > 0 ? `${preview}${suffix}` : null,
|
|
4893
|
+
isPinned ? 'click to release pin (Esc to clear)' : 'click to pin · hover to preview',
|
|
4894
|
+
].filter(Boolean).join('\n');
|
|
4895
|
+
})()}</title>
|
|
4896
|
+
{/* R55 hitbox covers the row so cursor doesn't need to
|
|
4897
|
+
be exactly on the 5-px swatch. R105 / Loop: the
|
|
4898
|
+
hitbox now also carries a subtle hover/pin tint —
|
|
4899
|
+
mirroring R104's recent-signal row treatment so
|
|
4900
|
+
both side panels share the list-item idiom. The
|
|
4901
|
+
tint borrows the ROW'S OWN swatch colour rather
|
|
4902
|
+
than legendAccent, so the user's eye associates
|
|
4903
|
+
the tinted row with its colour swatch instead of
|
|
4904
|
+
a generic accent. Pin is a stronger tint than
|
|
4905
|
+
hover; idle stays fully transparent. */}
|
|
4906
|
+
<rect
|
|
4907
|
+
x="6" y={row.y0 - 12}
|
|
4908
|
+
width="170" height="22" rx="3"
|
|
4909
|
+
fill={hoveredStatus === row.key || isPinned ? row.fill : 'transparent'}
|
|
4910
|
+
opacity={isPinned ? (isLight ? 0.14 : 0.18)
|
|
4911
|
+
: hoveredStatus === row.key ? (isLight ? 0.08 : 0.12)
|
|
4912
|
+
: 1}
|
|
4913
|
+
data-legend-row-tinted={isPinned ? 'pinned' : hoveredStatus === row.key ? 'hover' : 'none'}
|
|
4914
|
+
style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
|
|
4915
|
+
/>
|
|
4916
|
+
<circle cx="16" cy={row.y0} r="5.5" fill={row.fill} />
|
|
4917
|
+
{/* R61 pinned-state ring — concentric stroke at r=8 in
|
|
4918
|
+
the row colour, draws OUTSIDE the swatch so it
|
|
4919
|
+
doesn't fight the fill colour the user is matching.
|
|
4920
|
+
Round 181 / Loop: the ring used to mount/unmount
|
|
4921
|
+
with the conditional render, snapping on every
|
|
4922
|
+
pin/unpin. Now always mounted with opacity gated
|
|
4923
|
+
by isPinned + a 150ms transition so the ring
|
|
4924
|
+
eases in on pin and out on unpin — same gesture
|
|
4925
|
+
vocabulary the R165/R180 smooth-pin-mirror family
|
|
4926
|
+
uses for the chip-row pin chips. strokeWidth=1.5
|
|
4927
|
+
is the R51 overlap-test sentinel but the test
|
|
4928
|
+
selector is gated to g[data-node] ancestors,
|
|
4929
|
+
so this legend-internal circle is invisible to
|
|
4930
|
+
that probe. pointerEvents:none so the ring can't
|
|
4931
|
+
intercept the row click that produced it. */}
|
|
4932
|
+
<circle
|
|
4933
|
+
cx="16" cy={row.y0} r="8"
|
|
4934
|
+
fill="none"
|
|
4935
|
+
stroke={row.fill}
|
|
4936
|
+
strokeWidth="1.5"
|
|
4937
|
+
opacity={isPinned ? 1 : 0}
|
|
4938
|
+
data-legend-pin-ring={row.key}
|
|
4939
|
+
data-legend-pin-ring-pinned={isPinned ? 'true' : 'false'}
|
|
4940
|
+
style={{
|
|
4941
|
+
pointerEvents: 'none',
|
|
4942
|
+
transition: 'opacity 150ms ease-out',
|
|
4943
|
+
}}
|
|
4944
|
+
/>
|
|
4945
|
+
<text
|
|
4946
|
+
x="30" y={row.y1}
|
|
4947
|
+
fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
|
|
4948
|
+
fontSize="11"
|
|
4949
|
+
fontFamily="monospace"
|
|
4950
|
+
style={{ transition: 'fill 150ms ease-out' }}
|
|
4951
|
+
>{row.label}</text>
|
|
4952
|
+
{/* R95: live count anchored to the right edge of the
|
|
4953
|
+
panel (x=215, after the flow-arrow swatch). Same
|
|
4954
|
+
counts the chip-row shows ("3 working" etc.) but
|
|
4955
|
+
here next to the swatch the user is matching —
|
|
4956
|
+
saves crossing the canvas to the chip row for
|
|
4957
|
+
the number. text-anchor=end aligns the column
|
|
4958
|
+
visually like a table. pointerEvents:none so the
|
|
4959
|
+
count doesn't intercept the row hover hitbox. */}
|
|
4960
|
+
<text
|
|
4961
|
+
x="215" y={row.y1}
|
|
4962
|
+
textAnchor="end"
|
|
4963
|
+
fill={pal.legendText}
|
|
4964
|
+
fontSize="11"
|
|
4965
|
+
fontFamily="monospace"
|
|
4966
|
+
opacity={hoveredStatus === row.key || isPinned ? 0.95 : 0.65}
|
|
4967
|
+
data-legend-count={row.key}
|
|
4968
|
+
style={{ pointerEvents: 'none', transition: 'opacity 150ms ease-out' }}
|
|
4969
|
+
>{row.count}</text>
|
|
4970
|
+
</g>
|
|
4971
|
+
);
|
|
4972
|
+
})}
|
|
4973
|
+
{/* Flow-arrow swatch tracks the offline row — R106 shifted
|
|
4974
|
+
rows down by 8 px to make space for the panel header so
|
|
4975
|
+
this moves from y=72 to y=80. Drop its pointerEvents so
|
|
4976
|
+
the offline legend row stays hoverable (R55). It's
|
|
4977
|
+
decoration, no need to receive events. */}
|
|
4978
|
+
<path d="M140,80 Q164,56 196,80" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" style={{ pointerEvents: 'none' }} />
|
|
1440
4979
|
</g>
|
|
1441
4980
|
</svg>
|
|
1442
4981
|
|
|
4982
|
+
{/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
|
|
4983
|
+
zoom let users lose their position — the minimap shows the
|
|
4984
|
+
whole topology miniaturised plus a viewport rectangle so the
|
|
4985
|
+
user always knows where they are. Click anywhere to recenter
|
|
4986
|
+
the canvas there. Only mounted when the view is non-default
|
|
4987
|
+
(zoomed or panned) since at 1× centered the minimap and the
|
|
4988
|
+
canvas show the same thing. HTML overlay so it stays fixed
|
|
4989
|
+
while the SVG transforms. */}
|
|
4990
|
+
{(() => {
|
|
4991
|
+
const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
|
|
4992
|
+
if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
|
|
4993
|
+
const MW = 120, MH = 82;
|
|
4994
|
+
const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
|
|
4995
|
+
const rectX = (-view.x / view.zoom) * sx;
|
|
4996
|
+
const rectY = (-view.y / view.zoom) * sy;
|
|
4997
|
+
const rectW = (VIEWBOX_W / view.zoom) * sx;
|
|
4998
|
+
const rectH = (VIEWBOX_H / view.zoom) * sy;
|
|
4999
|
+
return (
|
|
5000
|
+
<div
|
|
5001
|
+
className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in anet-topo-chip-focus"
|
|
5002
|
+
style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair', color: pal.legendAccent }}
|
|
5003
|
+
// R157: minimap a11y completion. Pre-R157 the element had
|
|
5004
|
+
// role="img" + aria-label but no tabIndex / onKeyDown — it
|
|
5005
|
+
// was clickable for mouse users (recenter to where you
|
|
5006
|
+
// clicked) but tab-unreachable. role="img" was also wrong
|
|
5007
|
+
// for an interactive surface; role="button" matches the
|
|
5008
|
+
// canonical pattern R116 / R139 / R140 / R151 / R152 use.
|
|
5009
|
+
// Keyboard activation can't compute a click position, so
|
|
5010
|
+
// Enter / Space falls back to resetView() — same gesture
|
|
5011
|
+
// as the dedicated reset button (R104). Click semantics
|
|
5012
|
+
// unchanged; only added a clarifying tail to the aria-
|
|
5013
|
+
// label + title. anet-topo-chip-focus picks up R155's
|
|
5014
|
+
// cyan outline via color: pal.legendAccent inline so the
|
|
5015
|
+
// currentColor inherits cleanly on the rounded card.
|
|
5016
|
+
role="button"
|
|
5017
|
+
tabIndex={0}
|
|
5018
|
+
aria-label="Topology minimap — click to recenter, Enter to reset view"
|
|
5019
|
+
title="Minimap · click to recenter · Enter to reset view"
|
|
5020
|
+
onClick={(e) => {
|
|
5021
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
5022
|
+
const fx = (e.clientX - r.left) / r.width;
|
|
5023
|
+
const fy = (e.clientY - r.top) / r.height;
|
|
5024
|
+
setView(prev => ({
|
|
5025
|
+
...prev,
|
|
5026
|
+
x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
|
|
5027
|
+
y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
|
|
5028
|
+
}));
|
|
5029
|
+
}}
|
|
5030
|
+
onKeyDown={(e) => {
|
|
5031
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
5032
|
+
e.preventDefault();
|
|
5033
|
+
resetView();
|
|
5034
|
+
}
|
|
5035
|
+
}}
|
|
5036
|
+
data-topo-minimap
|
|
5037
|
+
>
|
|
5038
|
+
<svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
|
|
5039
|
+
{[...onlineNodes, ...offlineNodes].map(s => {
|
|
5040
|
+
const p = nodePositions[s.alias];
|
|
5041
|
+
if (!p) return null;
|
|
5042
|
+
const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
|
|
5043
|
+
const isOn = s.status !== 'offline' || !!sseN;
|
|
5044
|
+
const st = nodeStatus(s, isOn, isLight);
|
|
5045
|
+
return <circle key={s.alias} cx={p.x * sx} cy={p.y * sy} r={isOn ? 1.7 : 1.2} fill={st.primary} opacity={isOn ? 0.9 : 0.5} />;
|
|
5046
|
+
})}
|
|
5047
|
+
{/* viewport rectangle */}
|
|
5048
|
+
<rect
|
|
5049
|
+
x={Math.max(0, rectX)} y={Math.max(0, rectY)}
|
|
5050
|
+
width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
|
|
5051
|
+
height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
|
|
5052
|
+
fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
|
|
5053
|
+
/>
|
|
5054
|
+
</svg>
|
|
5055
|
+
</div>
|
|
5056
|
+
);
|
|
5057
|
+
})()}
|
|
1443
5058
|
{/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
|
|
1444
5059
|
overlay so they stay fixed while the SVG content transforms.
|
|
1445
5060
|
Round 104: Vincent 实测 — the reset action used to be hidden
|
|
1446
5061
|
behind the "%" label (looked like an indicator, not a button).
|
|
1447
5062
|
Split into a plain % readout + an explicit reset button with
|
|
1448
5063
|
its own icon + tooltip. */}
|
|
1449
|
-
<div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none">
|
|
1450
|
-
{/* #113: node size — S / M / L segmented control (Vincent 4727).
|
|
5064
|
+
<div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none" data-topo-chrome>
|
|
5065
|
+
{/* #113: node size — S / M / L segmented control (Vincent 4727).
|
|
5066
|
+
R154: stable data-* hooks for tests + focus-visible ring so
|
|
5067
|
+
keyboard navigation lands somewhere visible against the
|
|
5068
|
+
dark canvas (browser default outline often vanishes on
|
|
5069
|
+
cyber theme). */}
|
|
1451
5070
|
<div
|
|
1452
5071
|
className="flex items-center rounded-md border overflow-hidden"
|
|
1453
5072
|
style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
|
|
@@ -1459,8 +5078,20 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1459
5078
|
key={lbl}
|
|
1460
5079
|
onClick={() => pickNodeScale(v)}
|
|
1461
5080
|
aria-pressed={nodeScale === v}
|
|
5081
|
+
data-topo-chrome-nodesize={lbl}
|
|
5082
|
+
data-topo-chrome-nodesize-active={nodeScale === v ? 'true' : 'false'}
|
|
1462
5083
|
title={`Node size: ${lbl === 'S' ? 'small' : lbl === 'M' ? 'medium' : 'large'}`}
|
|
1463
|
-
|
|
5084
|
+
// Round 179 / Loop: nodeSize S/M/L active-button hover
|
|
5085
|
+
// variant closes the inconsistency with R163 layout
|
|
5086
|
+
// toggle and R178 fullscreen. Pre-R179 the active
|
|
5087
|
+
// (selected) S/M/L button had bg-cyan-500/15 + text-
|
|
5088
|
+
// cyan-300 but NO hover response — the chip looked
|
|
5089
|
+
// 'locked', not 'still interactive'. R163 and R178
|
|
5090
|
+
// both add hover:bg-cyan-500/20 on the active variant
|
|
5091
|
+
// so the active chip stays responsive to mouse. R179
|
|
5092
|
+
// closes the trio so all three chrome active-cyan
|
|
5093
|
+
// surfaces ship the same gesture.
|
|
5094
|
+
className={`px-2 py-1 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300 hover:bg-cyan-500/20' : 'hover:bg-white/5'}`}
|
|
1464
5095
|
style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
|
|
1465
5096
|
>
|
|
1466
5097
|
{lbl}
|
|
@@ -1472,45 +5103,134 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1472
5103
|
style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
|
|
1473
5104
|
>
|
|
1474
5105
|
<button
|
|
1475
|
-
onClick={() =>
|
|
1476
|
-
|
|
5106
|
+
onClick={() => { popChrome('zoom-out'); zoomByDiscrete(1 / 1.2); }}
|
|
5107
|
+
data-topo-chrome-zoom-out
|
|
5108
|
+
data-topo-chrome-zoom-out-popping={chromePopping === 'zoom-out' ? 'true' : 'false'}
|
|
5109
|
+
className="px-2 py-1 hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
|
|
1477
5110
|
style={{ color: pal.legendText }}
|
|
1478
5111
|
aria-label="Zoom out"
|
|
5112
|
+
title="Zoom out (−)"
|
|
1479
5113
|
>
|
|
1480
|
-
|
|
5114
|
+
{/* R186: icon pop on click. CSS animation runs once;
|
|
5115
|
+
React removes the class after 240ms so a quick
|
|
5116
|
+
re-click can replay. */}
|
|
5117
|
+
<svg
|
|
5118
|
+
width="12" height="12" viewBox="0 0 24 24"
|
|
5119
|
+
fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
|
|
5120
|
+
aria-hidden
|
|
5121
|
+
className={chromePopping === 'zoom-out' ? 'anet-chrome-pop' : undefined}
|
|
5122
|
+
data-topo-chrome-zoom-out-icon
|
|
5123
|
+
><path d="M5 12h14" /></svg>
|
|
1481
5124
|
</button>
|
|
5125
|
+
{/* Round 192 / Loop: zoom-level readout span participates in the
|
|
5126
|
+
R186 click-feel pop alongside the +/− icons. Pre-R192 a click
|
|
5127
|
+
on + or − triggered:
|
|
5128
|
+
· icon pop (R186, ~220ms scale 1→1.06→1)
|
|
5129
|
+
· canvas crossfade (R169, ~280ms opacity blend)
|
|
5130
|
+
· readout text snap (instant — 100% → 120%)
|
|
5131
|
+
The readout was the only surface that didn't acknowledge the
|
|
5132
|
+
gesture. Reusing the existing .anet-chrome-pop CSS keyframe
|
|
5133
|
+
(no new keyframes) lets the "%" number gently bounce in
|
|
5134
|
+
sync with the icon — same 0.22s ease-out, transform-origin
|
|
5135
|
+
center. transform-box: fill-box on the keyframe is
|
|
5136
|
+
SVG-specific and harmlessly ignored on this HTML span. The
|
|
5137
|
+
base layout classes (px / border / tabular-nums / minWidth)
|
|
5138
|
+
stay intact; only when chromePopping is 'zoom-in' or
|
|
5139
|
+
'zoom-out' do we splice in the animation class. Same
|
|
5140
|
+
React-clears-after-240ms cleanup R186 already runs, so the
|
|
5141
|
+
class can replay on a repeat click. */}
|
|
1482
5142
|
<span
|
|
1483
|
-
className=
|
|
1484
|
-
|
|
5143
|
+
className={`px-2 py-1 tabular-nums border-x text-center${
|
|
5144
|
+
chromePopping === 'zoom-in' || chromePopping === 'zoom-out'
|
|
5145
|
+
? ' anet-chrome-pop' : ''
|
|
5146
|
+
}`}
|
|
5147
|
+
data-topo-chrome-zoom-level
|
|
5148
|
+
data-topo-chrome-zoom-level-popping={
|
|
5149
|
+
chromePopping === 'zoom-in' || chromePopping === 'zoom-out'
|
|
5150
|
+
? 'true' : 'false'
|
|
5151
|
+
}
|
|
5152
|
+
style={{
|
|
5153
|
+
color: pal.legendText,
|
|
5154
|
+
borderColor: pal.containerBorder,
|
|
5155
|
+
minWidth: 46,
|
|
5156
|
+
display: 'inline-block',
|
|
5157
|
+
}}
|
|
1485
5158
|
title="Current zoom level"
|
|
1486
5159
|
>
|
|
1487
5160
|
{Math.round(view.zoom * 100)}%
|
|
1488
5161
|
</span>
|
|
1489
5162
|
<button
|
|
1490
|
-
onClick={() =>
|
|
1491
|
-
|
|
5163
|
+
onClick={() => { popChrome('zoom-in'); zoomByDiscrete(1.2); }}
|
|
5164
|
+
data-topo-chrome-zoom-in
|
|
5165
|
+
data-topo-chrome-zoom-in-popping={chromePopping === 'zoom-in' ? 'true' : 'false'}
|
|
5166
|
+
className="px-2 py-1 hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
|
|
1492
5167
|
style={{ color: pal.legendText }}
|
|
1493
5168
|
aria-label="Zoom in"
|
|
5169
|
+
title="Zoom in (+)"
|
|
1494
5170
|
>
|
|
1495
|
-
|
|
5171
|
+
{/* R186: icon pop on click. Same one-shot CSS animation
|
|
5172
|
+
as zoom-out; React removes the class after 240ms. */}
|
|
5173
|
+
<svg
|
|
5174
|
+
width="12" height="12" viewBox="0 0 24 24"
|
|
5175
|
+
fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
|
|
5176
|
+
aria-hidden
|
|
5177
|
+
className={chromePopping === 'zoom-in' ? 'anet-chrome-pop' : undefined}
|
|
5178
|
+
data-topo-chrome-zoom-in-icon
|
|
5179
|
+
><path d="M12 5v14M5 12h14" /></svg>
|
|
1496
5180
|
</button>
|
|
1497
5181
|
</div>
|
|
1498
5182
|
<button
|
|
1499
|
-
onClick={resetView}
|
|
1500
|
-
|
|
5183
|
+
onClick={() => { armResetSpin(); resetView(); }}
|
|
5184
|
+
data-topo-chrome-reset
|
|
5185
|
+
data-topo-chrome-reset-spinning={resetSpinning ? 'true' : 'false'}
|
|
5186
|
+
className="p-1.5 rounded-md border hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
|
|
1501
5187
|
style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
|
|
1502
5188
|
aria-label="Reset view"
|
|
1503
|
-
title="Reset zoom + pan (or double-click the canvas)"
|
|
5189
|
+
title="Reset zoom + pan (0, or double-click the canvas)"
|
|
1504
5190
|
>
|
|
1505
|
-
|
|
5191
|
+
{/* R184: the refresh-arrow icon does one counter-clockwise
|
|
5192
|
+
rotation on click. CSS animation runs once; React removes
|
|
5193
|
+
the className after 460ms (just past the 450ms duration)
|
|
5194
|
+
so a subsequent click can replay. */}
|
|
5195
|
+
<svg
|
|
5196
|
+
width="13" height="13" viewBox="0 0 24 24"
|
|
5197
|
+
fill="none" stroke="currentColor" strokeWidth="2"
|
|
5198
|
+
strokeLinecap="round" strokeLinejoin="round"
|
|
5199
|
+
aria-hidden
|
|
5200
|
+
className={resetSpinning ? 'anet-reset-spin' : undefined}
|
|
5201
|
+
data-topo-chrome-reset-icon
|
|
5202
|
+
>
|
|
1506
5203
|
<path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />
|
|
1507
5204
|
<path d="M3 3v5h5" />
|
|
1508
5205
|
</svg>
|
|
1509
5206
|
</button>
|
|
5207
|
+
{/* Round 178 / Loop: fullscreen chrome button picks up the
|
|
5208
|
+
active-state visual indicator R163 introduced for the
|
|
5209
|
+
Ring/Grid layout toggle. Pre-R178 the button changed
|
|
5210
|
+
icon when isFullscreen flipped but its background +
|
|
5211
|
+
foreground stayed unchanged — operators in fullscreen
|
|
5212
|
+
didn't get a strong 'you're in fullscreen' cue. Adding
|
|
5213
|
+
the bg-cyan-500/15 + text-cyan-300 active variant
|
|
5214
|
+
mirrors R163's pattern; hover variants tier 1
|
|
5215
|
+
brighter (cyan-500/20) when active so the chip
|
|
5216
|
+
continues to respond to mouse. Inline style now omits
|
|
5217
|
+
background + color when active so the Tailwind cyan
|
|
5218
|
+
classes win specificity. */}
|
|
1510
5219
|
<button
|
|
1511
5220
|
onClick={toggleFullscreen}
|
|
1512
|
-
|
|
1513
|
-
|
|
5221
|
+
data-topo-chrome-fullscreen
|
|
5222
|
+
data-topo-chrome-fullscreen-active={isFullscreen ? 'true' : 'false'}
|
|
5223
|
+
className={`p-1.5 rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 ${
|
|
5224
|
+
isFullscreen
|
|
5225
|
+
? 'bg-cyan-500/15 text-cyan-300 hover:bg-cyan-500/20'
|
|
5226
|
+
: 'hover:bg-white/5'
|
|
5227
|
+
}`}
|
|
5228
|
+
style={{
|
|
5229
|
+
borderColor: pal.containerBorder,
|
|
5230
|
+
...(isFullscreen
|
|
5231
|
+
? {}
|
|
5232
|
+
: { background: pal.legendBox.fill, color: pal.legendText }),
|
|
5233
|
+
}}
|
|
1514
5234
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
|
1515
5235
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
1516
5236
|
>
|