From 7b9dc32d6a925bc9c93b03f353826b52e710c250 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:18:28 +0100 Subject: [PATCH] Increase node height, add styled tooltips, make legend collapsible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #68: Increase FIXED_H from 40→52 for better edge visibility - #67: Replace native tooltips with styled HTML overlay showing node type, label, execution status and duration - #66: Legend starts collapsed as small pill, expands on click with close button Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- ui/src/pages/routes/diagram/DiagramCanvas.tsx | 39 +++++- ui/src/pages/routes/diagram/DiagramLegend.tsx | 22 ++++ ui/src/pages/routes/diagram/DiagramNode.tsx | 39 ++++-- .../pages/routes/diagram/RouteDiagramSvg.tsx | 6 +- .../pages/routes/diagram/diagram.module.css | 117 ++++++++++++++++++ 5 files changed, 210 insertions(+), 13 deletions(-) diff --git a/ui/src/pages/routes/diagram/DiagramCanvas.tsx b/ui/src/pages/routes/diagram/DiagramCanvas.tsx index db9526f0..e17f27df 100644 --- a/ui/src/pages/routes/diagram/DiagramCanvas.tsx +++ b/ui/src/pages/routes/diagram/DiagramCanvas.tsx @@ -5,6 +5,7 @@ import type { OverlayState } from '../../../hooks/useExecutionOverlay'; import { RouteDiagramSvg } from './RouteDiagramSvg'; import { DiagramMinimap } from './DiagramMinimap'; import { DiagramLegend } from './DiagramLegend'; +import type { TooltipData } from './DiagramNode'; import styles from './diagram.module.css'; interface DiagramCanvasProps { @@ -17,6 +18,15 @@ export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) { const svgWrapRef = useRef<HTMLDivElement>(null); const panzoomRef = useRef<PanZoom | null>(null); const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 800, h: 600 }); + const [tooltip, setTooltip] = useState<{ data: TooltipData; x: number; y: number } | null>(null); + + const handleNodeHover = useCallback((data: TooltipData | null, x: number, y: number) => { + if (!data) { + setTooltip(null); + } else { + setTooltip({ data, x, y }); + } + }, []); useEffect(() => { if (!svgWrapRef.current) return; @@ -95,12 +105,39 @@ export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) { <div ref={containerRef} className={styles.canvas}> <div ref={svgWrapRef}> - <RouteDiagramSvg layout={layout} overlay={overlay} /> + <RouteDiagramSvg layout={layout} overlay={overlay} onNodeHover={handleNodeHover} /> </div> </div> <DiagramLegend /> + {/* Node tooltip */} + {tooltip && ( + <div + className={styles.nodeTooltip} + style={{ + left: tooltip.x, + top: tooltip.y, + }} + > + <div className={styles.tooltipHeader}> + <span className={styles.tooltipDot} style={{ background: tooltip.data.color }} /> + <span className={styles.tooltipType}>{tooltip.data.nodeType}</span> + </div> + <div className={styles.tooltipLabel}>{tooltip.data.label}</div> + {tooltip.data.isExecuted && ( + <div className={styles.tooltipMeta}> + <span className={tooltip.data.isError ? styles.tooltipStatusFailed : styles.tooltipStatusOk}> + {tooltip.data.isError ? 'FAILED' : 'OK'} + </span> + {tooltip.data.duration != null && ( + <span className={styles.tooltipDuration}>{tooltip.data.duration}ms</span> + )} + </div> + )} + </div> + )} + <DiagramMinimap nodes={layout.nodes ?? []} edges={layout.edges ?? []} diff --git a/ui/src/pages/routes/diagram/DiagramLegend.tsx b/ui/src/pages/routes/diagram/DiagramLegend.tsx index ca72fb07..cacaf4bb 100644 --- a/ui/src/pages/routes/diagram/DiagramLegend.tsx +++ b/ui/src/pages/routes/diagram/DiagramLegend.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import styles from './diagram.module.css'; interface LegendItem { @@ -54,8 +55,29 @@ function LegendRow({ item }: { item: LegendItem }) { } export function DiagramLegend() { + const [expanded, setExpanded] = useState(false); + + if (!expanded) { + return ( + <button + className={styles.legendToggle} + onClick={() => setExpanded(true)} + title="Show legend" + > + Legend + </button> + ); + } + return ( <div className={styles.legend}> + <button + className={styles.legendCloseBtn} + onClick={() => setExpanded(false)} + title="Hide legend" + > + × + </button> <div className={styles.legendSection}> <span className={styles.legendTitle}>Nodes</span> {NODE_TYPES.map((t) => <LegendRow key={t.label} item={t} />)} diff --git a/ui/src/pages/routes/diagram/DiagramNode.tsx b/ui/src/pages/routes/diagram/DiagramNode.tsx index a19992c8..b07288fb 100644 --- a/ui/src/pages/routes/diagram/DiagramNode.tsx +++ b/ui/src/pages/routes/diagram/DiagramNode.tsx @@ -3,7 +3,7 @@ import { getNodeStyle, isCompoundType } from './nodeStyles'; import styles from './diagram.module.css'; const FIXED_W = 200; -const FIXED_H = 40; +const FIXED_H = 52; const MAX_LABEL = 22; function truncateLabel(label: string | undefined): string { @@ -11,13 +11,13 @@ function truncateLabel(label: string | undefined): string { return label.length > MAX_LABEL ? label.slice(0, MAX_LABEL - 1) + '\u2026' : label; } -function buildTooltip(node: PositionedNode, isOverlayActive: boolean, isExecuted: boolean, isError: boolean, duration?: number): string { - const parts = [`${node.type ?? 'PROCESSOR'}: ${node.label ?? ''}`]; - if (isOverlayActive && isExecuted) { - parts.push(`Status: ${isError ? 'FAILED' : 'OK'}`); - if (duration != null) parts.push(`Duration: ${duration}ms`); - } - return parts.join('\n'); +export interface TooltipData { + nodeType: string; + label: string; + color: string; + isExecuted: boolean; + isError: boolean; + duration?: number; } interface DiagramNodeProps { @@ -29,6 +29,7 @@ interface DiagramNodeProps { sequence?: number; isSelected: boolean; onClick: (nodeId: string) => void; + onHover?: (data: TooltipData | null, x: number, y: number) => void; } export function DiagramNode({ @@ -40,6 +41,7 @@ export function DiagramNode({ sequence, isSelected, onClick, + onHover, }: DiagramNodeProps) { const style = getNodeStyle(node.type ?? 'PROCESSOR'); const isCompound = isCompoundType(node.type ?? ''); @@ -53,7 +55,20 @@ export function DiagramNode({ ? (isError ? '#f85149' : '#3fb950') : style.border; - const tooltip = buildTooltip(node, isOverlayActive, isExecuted, isError, duration); + const handleMouseEnter = (e: React.MouseEvent) => { + onHover?.({ + nodeType: node.type ?? 'PROCESSOR', + label: node.label ?? '', + color: style.border, + isExecuted: isOverlayActive && isExecuted, + isError, + duration, + }, e.clientX, e.clientY); + }; + + const handleMouseLeave = () => { + onHover?.(null, 0, 0); + }; if (isCompound) { return ( @@ -62,8 +77,9 @@ export function DiagramNode({ opacity={dimmed ? 0.15 : 1} role="img" aria-label={`${node.type} container: ${node.label}`} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} > - <title>{tooltip} node.id && onClick(node.id)} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} style={{ cursor: 'pointer' }} role="img" aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`} tabIndex={0} > - {tooltip} void; } /** Recursively flatten all nodes (including compound children) for rendering */ @@ -24,7 +26,7 @@ function flattenNodes(nodes: PositionedNode[]): PositionedNode[] { return result; } -export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) { +export function RouteDiagramSvg({ layout, overlay, onNodeHover }: RouteDiagramSvgProps) { const padding = 40; const width = (layout.width ?? 600) + padding * 2; const height = (layout.height ?? 400) + padding * 2; @@ -58,6 +60,7 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) { sequence={undefined} isSelected={overlay.selectedNodeId === node.id} onClick={overlay.selectNode} + onHover={onNodeHover} /> {/* Iteration count badge */} {overlay.isActive && iterData && iterData.count > 1 && ( @@ -116,6 +119,7 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) { sequence={overlay.sequences.get(nodeId)} isSelected={overlay.selectedNodeId === nodeId} onClick={overlay.selectNode} + onHover={onNodeHover} /> ); })} diff --git a/ui/src/pages/routes/diagram/diagram.module.css b/ui/src/pages/routes/diagram/diagram.module.css index fcdce4e6..16efb2fd 100644 --- a/ui/src/pages/routes/diagram/diagram.module.css +++ b/ui/src/pages/routes/diagram/diagram.module.css @@ -409,7 +409,102 @@ color: var(--text-muted); } +/* ─── Node Tooltip ─── */ +.nodeTooltip { + position: fixed; + transform: translate(12px, -50%); + background: rgba(13, 17, 23, 0.95); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + z-index: 100; + pointer-events: none; + backdrop-filter: blur(8px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + min-width: 140px; + max-width: 280px; +} + +.tooltipHeader { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.tooltipDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.tooltipType { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-muted); +} + +.tooltipLabel { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + word-break: break-all; +} + +.tooltipMeta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--border-subtle); + font-family: var(--font-mono); + font-size: 11px; +} + +.tooltipStatusOk { + color: var(--green); + font-weight: 600; +} + +.tooltipStatusFailed { + color: var(--rose); + font-weight: 600; +} + +.tooltipDuration { + color: var(--text-secondary); +} + /* ─── Legend ─── */ +.legendToggle { + position: absolute; + bottom: 12px; + left: 12px; + padding: 5px 12px; + background: rgba(13, 17, 23, 0.85); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + cursor: pointer; + z-index: 10; + backdrop-filter: blur(4px); + transition: all 0.15s; +} + +.legendToggle:hover { + background: rgba(13, 17, 23, 0.95); + color: var(--text-secondary); + border-color: var(--border); +} + .legend { position: absolute; bottom: 12px; @@ -424,6 +519,24 @@ backdrop-filter: blur(4px); } +.legendCloseBtn { + position: absolute; + top: 4px; + right: 6px; + background: none; + border: none; + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + transition: color 0.15s; +} + +.legendCloseBtn:hover { + color: var(--text-primary); +} + .legendSection { display: flex; flex-direction: column; @@ -485,4 +598,8 @@ .legend { display: none; } + + .legendToggle { + display: none; + } }