Increase node height, add styled tooltips, make legend collapsible
All checks were successful
CI / build (push) Successful in 1m6s
CI / docker (push) Successful in 53s
CI / deploy (push) Successful in 30s

- #68: Increase FIXED_H from 40→52 for better edge visibility
- #67: Replace native <title> 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>
This commit is contained in:
hsiegeln
2026-03-15 10:18:28 +01:00
parent 8961a5a63c
commit 7b9dc32d6a
5 changed files with 210 additions and 13 deletions

View File

@@ -5,6 +5,7 @@ import type { OverlayState } from '../../../hooks/useExecutionOverlay';
import { RouteDiagramSvg } from './RouteDiagramSvg'; import { RouteDiagramSvg } from './RouteDiagramSvg';
import { DiagramMinimap } from './DiagramMinimap'; import { DiagramMinimap } from './DiagramMinimap';
import { DiagramLegend } from './DiagramLegend'; import { DiagramLegend } from './DiagramLegend';
import type { TooltipData } from './DiagramNode';
import styles from './diagram.module.css'; import styles from './diagram.module.css';
interface DiagramCanvasProps { interface DiagramCanvasProps {
@@ -17,6 +18,15 @@ export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) {
const svgWrapRef = useRef<HTMLDivElement>(null); const svgWrapRef = useRef<HTMLDivElement>(null);
const panzoomRef = useRef<PanZoom | null>(null); const panzoomRef = useRef<PanZoom | null>(null);
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 800, h: 600 }); 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(() => { useEffect(() => {
if (!svgWrapRef.current) return; if (!svgWrapRef.current) return;
@@ -95,12 +105,39 @@ export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) {
<div ref={containerRef} className={styles.canvas}> <div ref={containerRef} className={styles.canvas}>
<div ref={svgWrapRef}> <div ref={svgWrapRef}>
<RouteDiagramSvg layout={layout} overlay={overlay} /> <RouteDiagramSvg layout={layout} overlay={overlay} onNodeHover={handleNodeHover} />
</div> </div>
</div> </div>
<DiagramLegend /> <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 <DiagramMinimap
nodes={layout.nodes ?? []} nodes={layout.nodes ?? []}
edges={layout.edges ?? []} edges={layout.edges ?? []}

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import styles from './diagram.module.css'; import styles from './diagram.module.css';
interface LegendItem { interface LegendItem {
@@ -54,8 +55,29 @@ function LegendRow({ item }: { item: LegendItem }) {
} }
export function DiagramLegend() { export function DiagramLegend() {
const [expanded, setExpanded] = useState(false);
if (!expanded) {
return (
<button
className={styles.legendToggle}
onClick={() => setExpanded(true)}
title="Show legend"
>
Legend
</button>
);
}
return ( return (
<div className={styles.legend}> <div className={styles.legend}>
<button
className={styles.legendCloseBtn}
onClick={() => setExpanded(false)}
title="Hide legend"
>
&times;
</button>
<div className={styles.legendSection}> <div className={styles.legendSection}>
<span className={styles.legendTitle}>Nodes</span> <span className={styles.legendTitle}>Nodes</span>
{NODE_TYPES.map((t) => <LegendRow key={t.label} item={t} />)} {NODE_TYPES.map((t) => <LegendRow key={t.label} item={t} />)}

View File

@@ -3,7 +3,7 @@ import { getNodeStyle, isCompoundType } from './nodeStyles';
import styles from './diagram.module.css'; import styles from './diagram.module.css';
const FIXED_W = 200; const FIXED_W = 200;
const FIXED_H = 40; const FIXED_H = 52;
const MAX_LABEL = 22; const MAX_LABEL = 22;
function truncateLabel(label: string | undefined): string { 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; 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 { export interface TooltipData {
const parts = [`${node.type ?? 'PROCESSOR'}: ${node.label ?? ''}`]; nodeType: string;
if (isOverlayActive && isExecuted) { label: string;
parts.push(`Status: ${isError ? 'FAILED' : 'OK'}`); color: string;
if (duration != null) parts.push(`Duration: ${duration}ms`); isExecuted: boolean;
} isError: boolean;
return parts.join('\n'); duration?: number;
} }
interface DiagramNodeProps { interface DiagramNodeProps {
@@ -29,6 +29,7 @@ interface DiagramNodeProps {
sequence?: number; sequence?: number;
isSelected: boolean; isSelected: boolean;
onClick: (nodeId: string) => void; onClick: (nodeId: string) => void;
onHover?: (data: TooltipData | null, x: number, y: number) => void;
} }
export function DiagramNode({ export function DiagramNode({
@@ -40,6 +41,7 @@ export function DiagramNode({
sequence, sequence,
isSelected, isSelected,
onClick, onClick,
onHover,
}: DiagramNodeProps) { }: DiagramNodeProps) {
const style = getNodeStyle(node.type ?? 'PROCESSOR'); const style = getNodeStyle(node.type ?? 'PROCESSOR');
const isCompound = isCompoundType(node.type ?? ''); const isCompound = isCompoundType(node.type ?? '');
@@ -53,7 +55,20 @@ export function DiagramNode({
? (isError ? '#f85149' : '#3fb950') ? (isError ? '#f85149' : '#3fb950')
: style.border; : 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) { if (isCompound) {
return ( return (
@@ -62,8 +77,9 @@ export function DiagramNode({
opacity={dimmed ? 0.15 : 1} opacity={dimmed ? 0.15 : 1}
role="img" role="img"
aria-label={`${node.type} container: ${node.label}`} aria-label={`${node.type} container: ${node.label}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
> >
<title>{tooltip}</title>
<rect <rect
x={node.x} x={node.x}
y={node.y} y={node.y}
@@ -103,12 +119,13 @@ export function DiagramNode({
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''} ${isSelected ? styles.selected : ''}`} className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''} ${isSelected ? styles.selected : ''}`}
opacity={dimmed ? 0.15 : 1} opacity={dimmed ? 0.15 : 1}
onClick={() => node.id && onClick(node.id)} onClick={() => node.id && onClick(node.id)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
role="img" role="img"
aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`} aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`}
tabIndex={0} tabIndex={0}
> >
<title>{tooltip}</title>
<rect <rect
x={rx} x={rx}
y={ry} y={ry}

View File

@@ -3,6 +3,7 @@ import type { OverlayState } from '../../../hooks/useExecutionOverlay';
import { SvgDefs } from './SvgDefs'; import { SvgDefs } from './SvgDefs';
import { EdgeLayer } from './EdgeLayer'; import { EdgeLayer } from './EdgeLayer';
import { DiagramNode } from './DiagramNode'; import { DiagramNode } from './DiagramNode';
import type { TooltipData } from './DiagramNode';
import { FlowParticles } from './FlowParticles'; import { FlowParticles } from './FlowParticles';
import { isCompoundType } from './nodeStyles'; import { isCompoundType } from './nodeStyles';
import type { PositionedNode } from '../../../api/types'; import type { PositionedNode } from '../../../api/types';
@@ -10,6 +11,7 @@ import type { PositionedNode } from '../../../api/types';
interface RouteDiagramSvgProps { interface RouteDiagramSvgProps {
layout: DiagramLayout; layout: DiagramLayout;
overlay: OverlayState; overlay: OverlayState;
onNodeHover?: (data: TooltipData | null, x: number, y: number) => void;
} }
/** Recursively flatten all nodes (including compound children) for rendering */ /** Recursively flatten all nodes (including compound children) for rendering */
@@ -24,7 +26,7 @@ function flattenNodes(nodes: PositionedNode[]): PositionedNode[] {
return result; return result;
} }
export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) { export function RouteDiagramSvg({ layout, overlay, onNodeHover }: RouteDiagramSvgProps) {
const padding = 40; const padding = 40;
const width = (layout.width ?? 600) + padding * 2; const width = (layout.width ?? 600) + padding * 2;
const height = (layout.height ?? 400) + padding * 2; const height = (layout.height ?? 400) + padding * 2;
@@ -58,6 +60,7 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
sequence={undefined} sequence={undefined}
isSelected={overlay.selectedNodeId === node.id} isSelected={overlay.selectedNodeId === node.id}
onClick={overlay.selectNode} onClick={overlay.selectNode}
onHover={onNodeHover}
/> />
{/* Iteration count badge */} {/* Iteration count badge */}
{overlay.isActive && iterData && iterData.count > 1 && ( {overlay.isActive && iterData && iterData.count > 1 && (
@@ -116,6 +119,7 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
sequence={overlay.sequences.get(nodeId)} sequence={overlay.sequences.get(nodeId)}
isSelected={overlay.selectedNodeId === nodeId} isSelected={overlay.selectedNodeId === nodeId}
onClick={overlay.selectNode} onClick={overlay.selectNode}
onHover={onNodeHover}
/> />
); );
})} })}

View File

@@ -409,7 +409,102 @@
color: var(--text-muted); 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 ─── */ /* ─── 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 { .legend {
position: absolute; position: absolute;
bottom: 12px; bottom: 12px;
@@ -424,6 +519,24 @@
backdrop-filter: blur(4px); 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 { .legendSection {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -485,4 +598,8 @@
.legend { .legend {
display: none; display: none;
} }
.legendToggle {
display: none;
}
} }