mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
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>
234 lines
6.9 KiB
TypeScript
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);
|