Increase node height, add styled tooltips, make legend collapsible
- #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:
@@ -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 ?? []}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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} />)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user