constellation-analyzer/src/components/Nodes/CustomNode.tsx
Jan-Henrik Bruhn 66d47fb022 Fix duplicate label selection and add label wrapping
Label selection fix:
- Prevent duplicate labels when creating a label that already exists
- Check if label is already selected before adding to selection

Label wrapping improvements:
- Labels now wrap within a 200px container to prevent nodes growing too large
- LabelBadge updated to only truncate when maxWidth is explicitly provided
- Labels display full text without individual truncation
- Applies to both CustomNode and CustomEdge components

Note: Some overlap may occur with circular shapes - accepted for now.
2026-01-24 16:50:22 +01:00

290 lines
7.6 KiB
TypeScript

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<Actor>) => {
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 (
<div
className="relative"
style={{
opacity: nodeOpacity,
}}
>
{/* Invisible handles positioned around edges - center remains free for dragging */}
{/* Bidirectional handles (source + target overlapping at each edge) */}
{/* Top edge handles */}
<Handle
type="target"
position={Position.Top}
id="top-target"
isConnectable={true}
style={{
width: "100%",
height: "30px",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
<Handle
type="source"
position={Position.Top}
id="top-source"
isConnectable={true}
style={{
width: "100%",
height: "30px",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Right edge handles */}
<Handle
type="target"
position={Position.Right}
id="right-target"
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0,
right: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
<Handle
type="source"
position={Position.Right}
id="right-source"
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0,
right: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Bottom edge handles */}
<Handle
type="target"
position={Position.Bottom}
id="bottom-target"
isConnectable={true}
style={{
width: "100%",
height: "30px",
bottom: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom-source"
isConnectable={true}
style={{
width: "100%",
height: "30px",
bottom: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Left edge handles */}
<Handle
type="target"
position={Position.Left}
id="left-target"
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
<Handle
type="source"
position={Position.Left}
id="left-source"
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* 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" style={{ maxWidth: '200px' }}>
{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}
size="sm"
/>
);
})}
</div>
)}
</div>
</NodeShapeRenderer>
</div>
);
};
export default memo(CustomNode);