Phase 1: Extract 6 shared CSS modules (table-section, log-panel, rate-colors, refresh-indicator, chart-card, section-card) eliminating ~135 duplicate class definitions across 11 files. Phase 2: Replace all hardcoded hex colors in CSS modules with design system variables. Strip ~55 hex fallbacks from var() patterns. Fix 4 undefined variable names (--accent, --bg-base, --surface, --bg-surface-raised). Phase 3: Replace ~45 hardcoded hex values in ProcessDiagram SVG components with var() CSS custom properties. Fix Dashboard.tsx color prop. Phase 4: Create CSS modules for AdminLayout, DatabaseAdminPage, OidcCallback (previously 100% inline). Extract shared PageLoader component (replaces 3 copy-pasted spinner patterns). Move AppsTab static inline styles to CSS classes. Extract LayoutShell StarredList styles. 58 files changed, net -219 lines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
186 lines
6.4 KiB
TypeScript
186 lines
6.4 KiB
TypeScript
import { useCallback, useRef } from 'react';
|
|
import type { DiagramSection } from './types';
|
|
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
|
import type { NodeExecutionState } from '../ExecutionDiagram/types';
|
|
import { colorForType } from './node-colors';
|
|
import styles from './ProcessDiagram.module.css';
|
|
|
|
const MINIMAP_WIDTH = 140;
|
|
const MINIMAP_HEIGHT = 90;
|
|
const MINIMAP_PADDING = 4;
|
|
|
|
interface MinimapProps {
|
|
sections: DiagramSection[];
|
|
totalWidth: number;
|
|
totalHeight: number;
|
|
/** Current zoom/pan state */
|
|
scale: number;
|
|
translateX: number;
|
|
translateY: number;
|
|
/** Container pixel dimensions */
|
|
containerWidth: number;
|
|
containerHeight: number;
|
|
/** Called when user clicks/drags on minimap to pan */
|
|
onPan: (translateX: number, translateY: number) => void;
|
|
/** Execution overlay for coloring nodes */
|
|
executionOverlay?: Map<string, NodeExecutionState>;
|
|
}
|
|
|
|
export function Minimap({
|
|
sections, totalWidth, totalHeight,
|
|
scale, translateX, translateY,
|
|
containerWidth, containerHeight,
|
|
onPan, executionOverlay,
|
|
}: MinimapProps) {
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
const dragging = useRef(false);
|
|
|
|
if (totalWidth <= 0 || totalHeight <= 0) return null;
|
|
|
|
// Scale factor to fit diagram into minimap
|
|
const innerW = MINIMAP_WIDTH - MINIMAP_PADDING * 2;
|
|
const innerH = MINIMAP_HEIGHT - MINIMAP_PADDING * 2;
|
|
const miniScale = Math.min(innerW / totalWidth, innerH / totalHeight);
|
|
|
|
// Viewport rectangle in minimap coordinates
|
|
// The visible area in diagram-space: from (-translateX/scale) to (-translateX/scale + containerWidth/scale)
|
|
const vpX = (-translateX / scale) * miniScale + MINIMAP_PADDING;
|
|
const vpY = (-translateY / scale) * miniScale + MINIMAP_PADDING;
|
|
const vpW = (containerWidth / scale) * miniScale;
|
|
const vpH = (containerHeight / scale) * miniScale;
|
|
|
|
const handleClick = useCallback(
|
|
(e: React.MouseEvent<SVGSVGElement>) => {
|
|
const svg = svgRef.current;
|
|
if (!svg) return;
|
|
const rect = svg.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left - MINIMAP_PADDING;
|
|
const my = e.clientY - rect.top - MINIMAP_PADDING;
|
|
// Convert minimap click to diagram coordinates, then to translate
|
|
const diagramX = mx / miniScale;
|
|
const diagramY = my / miniScale;
|
|
// Center the viewport on the clicked point
|
|
const newTx = -(diagramX * scale) + containerWidth / 2;
|
|
const newTy = -(diagramY * scale) + containerHeight / 2;
|
|
onPan(newTx, newTy);
|
|
},
|
|
[miniScale, scale, containerWidth, containerHeight, onPan],
|
|
);
|
|
|
|
const handlePointerDown = useCallback(
|
|
(e: React.PointerEvent<SVGSVGElement>) => {
|
|
dragging.current = true;
|
|
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handlePointerMove = useCallback(
|
|
(e: React.PointerEvent<SVGSVGElement>) => {
|
|
if (!dragging.current) return;
|
|
const svg = svgRef.current;
|
|
if (!svg) return;
|
|
const rect = svg.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left - MINIMAP_PADDING;
|
|
const my = e.clientY - rect.top - MINIMAP_PADDING;
|
|
const diagramX = mx / miniScale;
|
|
const diagramY = my / miniScale;
|
|
const newTx = -(diagramX * scale) + containerWidth / 2;
|
|
const newTy = -(diagramY * scale) + containerHeight / 2;
|
|
onPan(newTx, newTy);
|
|
},
|
|
[miniScale, scale, containerWidth, containerHeight, onPan],
|
|
);
|
|
|
|
const handlePointerUp = useCallback(
|
|
(e: React.PointerEvent<SVGSVGElement>) => {
|
|
dragging.current = false;
|
|
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
|
|
},
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div className={styles.minimap}>
|
|
<svg
|
|
ref={svgRef}
|
|
width={MINIMAP_WIDTH}
|
|
height={MINIMAP_HEIGHT}
|
|
onClick={handleClick}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
style={{ cursor: 'crosshair' }}
|
|
>
|
|
{/* Background */}
|
|
<rect width={MINIMAP_WIDTH} height={MINIMAP_HEIGHT} fill="var(--bg-surface, #FFFFFF)" rx={3} />
|
|
|
|
<g transform={`translate(${MINIMAP_PADDING}, ${MINIMAP_PADDING}) scale(${miniScale})`}>
|
|
{/* Render simplified nodes for each section */}
|
|
{sections.map((section, si) => (
|
|
<g key={si} transform={`translate(0, ${section.offsetY})`}>
|
|
{renderMinimapNodes(section.nodes, executionOverlay)}
|
|
</g>
|
|
))}
|
|
</g>
|
|
|
|
{/* Viewport indicator */}
|
|
<rect
|
|
x={vpX}
|
|
y={vpY}
|
|
width={Math.max(vpW, 4)}
|
|
height={Math.max(vpH, 4)}
|
|
fill="var(--amber, #C6820E)"
|
|
fillOpacity={0.15}
|
|
stroke="var(--amber, #C6820E)"
|
|
strokeWidth={1.5}
|
|
rx={1}
|
|
/>
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function nodeColor(
|
|
node: DiagramNodeType,
|
|
overlay?: Map<string, NodeExecutionState>,
|
|
): { fill: string; opacity: number } {
|
|
if (!overlay) return { fill: colorForType(node.type), opacity: 0.7 };
|
|
const state = node.id ? overlay.get(node.id) : undefined;
|
|
if (state?.status === 'COMPLETED') return { fill: 'var(--success)', opacity: 0.85 };
|
|
if (state?.status === 'FAILED') return { fill: 'var(--error)', opacity: 0.85 };
|
|
// ENDPOINT is always traversed when overlay is active (route entry point)
|
|
if (node.type === 'ENDPOINT' && overlay.size > 0) return { fill: 'var(--success)', opacity: 0.85 };
|
|
// Skipped (overlay active but node not executed)
|
|
return { fill: 'var(--text-muted)', opacity: 0.35 };
|
|
}
|
|
|
|
function renderMinimapNodes(
|
|
nodes: DiagramNodeType[],
|
|
overlay?: Map<string, NodeExecutionState>,
|
|
): React.ReactNode[] {
|
|
const elements: React.ReactNode[] = [];
|
|
for (const node of nodes) {
|
|
const x = node.x ?? 0;
|
|
const y = node.y ?? 0;
|
|
const w = node.width ?? 160;
|
|
const h = node.height ?? 40;
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
const { fill, opacity } = nodeColor(node, overlay);
|
|
elements.push(
|
|
<rect key={node.id} x={x} y={y} width={w} height={h}
|
|
fill="none" stroke={fill} strokeWidth={2} rx={2} opacity={opacity} />,
|
|
);
|
|
elements.push(...renderMinimapNodes(node.children, overlay));
|
|
} else {
|
|
const { fill, opacity } = nodeColor(node, overlay);
|
|
elements.push(
|
|
<rect key={node.id} x={x} y={y} width={w} height={h}
|
|
fill={fill} rx={2} opacity={opacity} />,
|
|
);
|
|
}
|
|
}
|
|
return elements;
|
|
}
|