constellation-analyzer/src/components/Nodes/CustomNode.tsx
Jan-Henrik Bruhn b1e634d3c4 feat: add group minimize/maximize with floating edges and React Flow v12
Implements comprehensive group minimize/maximize functionality and migrates
to React Flow v12 (@xyflow/react) with improved edge routing.

## Group Minimize/Maximize Features:
- Minimized groups render as compact 220×80px solid rectangles
- Original dimensions preserved in metadata and restored on maximize
- Child actors hidden (not filtered) to prevent React Flow state issues
- Solid color backgrounds (transparency removed for minimized state)
- Internal edges filtered out when group is minimized
- Dimension sync before minimize ensures correct size on maximize

## Floating Edges:
- Dynamic edge routing for connections to/from minimized groups
- Edges connect to closest point on minimized group border
- Regular actors maintain fixed handle connections
- Smooth transitions when toggling group state

## React Flow v12 Migration:
- Updated package from 'reactflow' to '@xyflow/react'
- Changed imports to named imports (ReactFlow is now named)
- Updated CSS imports to '@xyflow/react/dist/style.css'
- Fixed NodeProps/EdgeProps to use full Node/Edge types
- Added Record<string, unknown> to data interfaces for v12 compatibility
- Replaced useStore(state => state.connectionNodeId) with useConnection()
- Updated nodeInternals to nodeLookup (renamed in v12)
- Fixed event handler types for v12 API changes

## Edge Label Improvements:
- Added explicit z-index (1000) to edge labels via EdgeLabelRenderer
- Labels now properly render above edge paths

## Type Safety & Code Quality:
- Removed all 'any' type assertions in useDocumentHistory
- Fixed missing React Hook dependencies
- Fixed unused variable warnings
- All ESLint checks passing (0 errors, 0 warnings)
- TypeScript compilation clean

## Bug Fixes:
- Group drag positions now properly persisted to store
- Minimized group styling (removed dotted border, padding)
- Node visibility using 'hidden' property instead of array filtering
- Dimension sync prevents actors from disappearing on maximize

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 11:52:44 +02:00

234 lines
6.9 KiB
TypeScript

import { memo, useMemo } from "react";
import { Handle, Position, NodeProps, useConnection } from "@xyflow/react";
import { useGraphStore } from "../../stores/graphStore";
import { useSearchStore } from "../../stores/searchStore";
import {
getContrastColor,
adjustColorBrightness,
} from "../../utils/colorUtils";
import { getIconComponent } from "../../utils/iconUtils";
import type { Actor } from "../../types";
import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
import LabelBadge from "../Common/LabelBadge";
/**
* CustomNode - Represents an actor in the constellation graph
*
* Features:
* - Visual representation with type-based coloring
* - Connection handles (top, right, bottom, left)
* - Label display
* - Type badge
*
* Usage: Automatically rendered by React Flow for nodes with type='custom'
*/
const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
const nodeTypes = useGraphStore((state) => state.nodeTypes);
const labels = useGraphStore((state) => state.labels);
const { searchText, selectedActorTypes, selectedLabels } = useSearchStore();
// Check if any connection is being made (to show handles)
const connection = useConnection();
const isConnecting = !!connection.inProgress;
// Find the node type configuration
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type);
const nodeColor = nodeTypeConfig?.color || "#6b7280";
const nodeLabel = nodeTypeConfig?.label || "Unknown";
const nodeShape = nodeTypeConfig?.shape || "rectangle";
const IconComponent = getIconComponent(nodeTypeConfig?.icon);
// Determine text color based on background
const textColor = getContrastColor(nodeColor);
const borderColor = selected
? adjustColorBrightness(nodeColor, -20)
: nodeColor;
// Show handles when selected or when connecting
const showHandles = selected || isConnecting;
// Check if this node matches the search and filter criteria
const isMatch = useMemo(() => {
// Check actor type filter (POSITIVE: if types selected, node must be one of them)
if (selectedActorTypes.length > 0) {
if (!selectedActorTypes.includes(data.type)) {
return false;
}
}
// Check label filter (POSITIVE: if labels selected, node must have at least one)
if (selectedLabels.length > 0) {
const nodeLabels = data.labels || [];
const hasSelectedLabel = nodeLabels.some((labelId) =>
selectedLabels.includes(labelId)
);
if (!hasSelectedLabel) {
return false;
}
}
// Check search text match
if (searchText.trim()) {
const searchLower = searchText.toLowerCase();
const label = data.label?.toLowerCase() || "";
const description = data.description?.toLowerCase() || "";
const typeName = nodeLabel.toLowerCase();
return (
label.includes(searchLower) ||
description.includes(searchLower) ||
typeName.includes(searchLower)
);
}
return true;
}, [
searchText,
selectedActorTypes,
selectedLabels,
data.type,
data.label,
data.labels,
data.description,
nodeLabel,
]);
// Determine if filters are active
const hasActiveFilters =
searchText.trim() !== "" ||
selectedActorTypes.length > 0 ||
selectedLabels.length > 0;
// Calculate opacity based on match status
const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
const isHighlighted = hasActiveFilters && isMatch;
return (
<div
className="relative"
style={{
opacity: nodeOpacity,
}}
>
{/* Connection handles - shown only when selected or connecting */}
<Handle
type="source"
position={Position.Top}
id="top"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
<Handle
type="source"
position={Position.Right}
id="right"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
<Handle
type="source"
position={Position.Left}
id="left"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
{/* Node content with shape renderer */}
<NodeShapeRenderer
shape={nodeShape}
color={nodeColor}
borderColor={borderColor}
textColor={textColor}
selected={selected}
isHighlighted={isHighlighted}
>
<div className="space-y-1">
{/* Icon (if available) */}
{IconComponent && (
<div
className="flex justify-center mb-1"
style={{ color: textColor, fontSize: "2rem" }}
>
<IconComponent />
</div>
)}
{/* Main label */}
<div
className="text-base font-bold text-center break-words leading-tight"
style={{ color: textColor }}
>
{data.label}
</div>
{/* Type as subtle subtitle */}
<div
className="text-xs text-center opacity-70 font-medium leading-tight"
style={{ color: textColor }}
>
{nodeLabel}
</div>
{/* Labels */}
{data.labels && data.labels.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center mt-2">
{data.labels.map((labelId) => {
const labelConfig = labels.find((l) => l.id === labelId);
if (!labelConfig) return null;
return (
<LabelBadge
key={labelId}
name={labelConfig.name}
color={labelConfig.color}
maxWidth="80px"
size="sm"
/>
);
})}
</div>
)}
</div>
</NodeShapeRenderer>
</div>
);
};
export default memo(CustomNode);