import { memo, useMemo } from "react"; import { Handle, Position, NodeProps } from "@xyflow/react"; import { useGraphStore } from "../../stores/graphStore"; 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"; import { useActiveFilters, nodeMatchesFilters, } from "../../hooks/useActiveFilters"; /** * CustomNode - Represents an actor in the constellation graph * * Features: * - Visual representation with type-based coloring * - Easy-connect: whole node is connectable, edges auto-route to nearest border point * - Label display * - Type badge * * Usage: Automatically rendered by React Flow for nodes with type='custom' */ const CustomNode = ({ data, selected }: NodeProps) => { const nodeTypes = useGraphStore((state) => state.nodeTypes); const labels = useGraphStore((state) => state.labels); // Get active filters based on mode (editing vs presentation) const filters = useActiveFilters(); // 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; // Check if this node matches the filter criteria const isMatch = useMemo(() => { return nodeMatchesFilters( data.type, data.labels || [], data.label || "", data.description || "", nodeLabel, filters, ); }, [ data.type, data.labels, data.label, data.description, nodeLabel, filters, ]); // Determine if filters are active const hasActiveFilters = filters.searchText.trim() !== "" || filters.selectedActorTypes.length > 0 || filters.selectedLabels.length > 0; // Calculate opacity based on match status const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; const isHighlighted = hasActiveFilters && isMatch; return (
{/* Invisible handles positioned around edges - center remains free for dragging */} {/* Bidirectional handles (source + target overlapping at each edge) */} {/* Top edge handles */} {/* Right edge handles */} {/* Bottom edge handles */} {/* Left edge handles */} {/* Node content with shape renderer */}
{/* Icon (if available) */} {IconComponent && (
)} {/* Main label */}
{data.label}
{/* Type as subtle subtitle */}
{nodeLabel}
{/* Labels */} {data.labels && data.labels.length > 0 && (
{data.labels.map((labelId) => { const labelConfig = labels.find((l) => l.id === labelId); if (!labelConfig) return null; return ( ); })}
)}
); }; export default memo(CustomNode);