mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
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.
290 lines
7.6 KiB
TypeScript
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);
|