mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 09:48:27 +00:00
1050 lines
34 KiB
TypeScript
1050 lines
34 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
useMemo,
|
|
useCallback,
|
|
useImperativeHandle,
|
|
forwardRef,
|
|
} from "react";
|
|
import * as d3 from "d3";
|
|
import colors from "tailwindcss/colors";
|
|
|
|
import type { GraphTriplet, IdValue, GraphNode } from "./type";
|
|
import {
|
|
createLabelColorMap,
|
|
getNodeColor as getNodeColorByLabel,
|
|
} from "./node-colors";
|
|
import { useTheme } from "remix-themes";
|
|
|
|
interface GraphProps {
|
|
triplets: GraphTriplet[];
|
|
width?: number;
|
|
height?: number;
|
|
zoomOnMount?: boolean;
|
|
onNodeClick?: (nodeId: string) => void;
|
|
onEdgeClick?: (edgeId: string) => void;
|
|
onBlur?: () => void;
|
|
labelColorMap?: Map<string, number>;
|
|
}
|
|
|
|
// Add ref type for zoomToLinkById
|
|
export interface GraphRef {
|
|
zoomToLinkById: (linkId: string) => void;
|
|
}
|
|
|
|
export const Graph = forwardRef<GraphRef, GraphProps>(
|
|
(
|
|
{
|
|
triplets,
|
|
width = 1000,
|
|
height = 800,
|
|
zoomOnMount = true,
|
|
onNodeClick,
|
|
onEdgeClick,
|
|
onBlur,
|
|
labelColorMap: externalLabelColorMap,
|
|
},
|
|
ref,
|
|
) => {
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
const [themeMode] = useTheme();
|
|
|
|
// Function refs to keep track of reset functions
|
|
const resetLinksRef = useRef<(() => void) | null>(null);
|
|
const resetNodesRef = useRef<(() => void) | null>(null);
|
|
const handleLinkClickRef = useRef<
|
|
((event: any, d: any, relation: IdValue) => void) | null
|
|
>(null);
|
|
const simulationRef = useRef<d3.Simulation<
|
|
d3.SimulationNodeDatum,
|
|
undefined
|
|
> | null>(null);
|
|
const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(
|
|
null,
|
|
);
|
|
const isInitializedRef = useRef(false);
|
|
|
|
// Add ref for zoomToLinkById
|
|
const graphRef = useRef<GraphRef>({
|
|
zoomToLinkById: (linkId: string) => {
|
|
if (
|
|
!svgRef.current ||
|
|
!resetLinksRef.current ||
|
|
!resetNodesRef.current ||
|
|
!handleLinkClickRef.current
|
|
)
|
|
return;
|
|
const svgElement = d3.select(svgRef.current);
|
|
const linkGroups = svgElement.selectAll("g > g"); // Select all link groups
|
|
|
|
let found = false;
|
|
|
|
// Iterate through link groups to find matching relation
|
|
linkGroups.each(function (d: any) {
|
|
if (found) return; // Skip if already found
|
|
|
|
if (d?.relationData) {
|
|
const relation = d.relationData.find(
|
|
(r: IdValue) => r.id === linkId,
|
|
);
|
|
if (relation) {
|
|
found = true;
|
|
const resetLinks = resetLinksRef.current;
|
|
const resetNodes = resetNodesRef.current;
|
|
const handleLinkClick = handleLinkClickRef.current;
|
|
|
|
if (resetLinks) resetLinks();
|
|
if (resetNodes) resetNodes();
|
|
if (handleLinkClick)
|
|
handleLinkClick({ stopPropagation: () => {} }, d, relation);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!found) {
|
|
console.warn(`Link with id ${linkId} not found`);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Expose the ref through forwardRef
|
|
useImperativeHandle(ref, () => graphRef.current);
|
|
|
|
// Memoize theme to prevent unnecessary recreation
|
|
const theme = useMemo(
|
|
() => ({
|
|
node: {
|
|
fill: colors.pink[500],
|
|
stroke: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
|
hover: colors.blue[400],
|
|
text: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
|
selected: colors.blue[500],
|
|
dimmed: colors.pink[300],
|
|
},
|
|
link: {
|
|
stroke: themeMode === "dark" ? colors.slate[600] : colors.slate[400],
|
|
selected: colors.blue[400],
|
|
dimmed: themeMode === "dark" ? colors.slate[800] : colors.slate[200],
|
|
label: {
|
|
bg: themeMode === "dark" ? colors.slate[800] : colors.slate[200],
|
|
text: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
|
},
|
|
},
|
|
background:
|
|
themeMode === "dark" ? colors.slate[900] : colors.slate[100],
|
|
controls: {
|
|
bg: themeMode === "dark" ? colors.slate[800] : colors.slate[200],
|
|
hover: themeMode === "dark" ? colors.slate[700] : colors.slate[300],
|
|
text: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
|
},
|
|
}),
|
|
[themeMode],
|
|
);
|
|
|
|
// Extract all unique labels from triplets
|
|
const allLabels = useMemo(() => {
|
|
// Only calculate if we need to create our own map
|
|
if (externalLabelColorMap) return [];
|
|
|
|
const labels = new Set<string>();
|
|
labels.add("Entity"); // Always include Entity as default
|
|
|
|
triplets.forEach((triplet) => {
|
|
if (triplet.source.primaryLabel)
|
|
labels.add(triplet.source.primaryLabel);
|
|
if (triplet.target.primaryLabel)
|
|
labels.add(triplet.target.primaryLabel);
|
|
});
|
|
|
|
return Array.from(labels);
|
|
}, [triplets, externalLabelColorMap]);
|
|
|
|
// Create a mapping of label to color
|
|
const labelColorMap = useMemo(() => {
|
|
return externalLabelColorMap || createLabelColorMap(allLabels);
|
|
}, [allLabels, externalLabelColorMap]);
|
|
|
|
// Create a mapping of node IDs to their data
|
|
const nodeDataMap = useMemo(() => {
|
|
const result = new Map<string, GraphNode>();
|
|
|
|
triplets.forEach((triplet) => {
|
|
result.set(triplet.source.id, triplet.source);
|
|
result.set(triplet.target.id, triplet.target);
|
|
});
|
|
|
|
return result;
|
|
}, [triplets]);
|
|
|
|
// Function to get node color
|
|
const getNodeColor = useCallback(
|
|
(node: any): string => {
|
|
if (!node) {
|
|
return getNodeColorByLabel(null, themeMode === "dark", labelColorMap);
|
|
}
|
|
|
|
// Get the full node data if we only have an ID
|
|
const nodeData = nodeDataMap.get(node.id) || node;
|
|
|
|
// Extract primaryLabel from node data
|
|
const primaryLabel = nodeData.primaryLabel;
|
|
|
|
return getNodeColorByLabel(
|
|
primaryLabel,
|
|
themeMode === "dark",
|
|
labelColorMap,
|
|
);
|
|
},
|
|
[labelColorMap, nodeDataMap, themeMode],
|
|
);
|
|
|
|
// Process graph data
|
|
const { nodes, links } = useMemo(() => {
|
|
const nodes = Array.from(
|
|
new Set(triplets.flatMap((t) => [t.source.id, t.target.id])),
|
|
).map((id) => {
|
|
const nodeData = triplets.find(
|
|
(t) => t.source.id === id || t.target.id === id,
|
|
);
|
|
const value = nodeData
|
|
? nodeData.source.id === id
|
|
? nodeData.source.value
|
|
: nodeData.target.value
|
|
: id;
|
|
return {
|
|
id,
|
|
value,
|
|
};
|
|
});
|
|
|
|
const linkGroups = triplets.reduce(
|
|
(groups, triplet) => {
|
|
// Skip isolated node edges (they are just placeholders for showing isolated nodes)
|
|
if (triplet.relation.type === "_isolated_node_") {
|
|
return groups;
|
|
}
|
|
|
|
let key = `${triplet.source.id}-${triplet.target.id}`;
|
|
const reverseKey = `${triplet.target.id}-${triplet.source.id}`;
|
|
|
|
if (groups[reverseKey]) {
|
|
key = reverseKey;
|
|
}
|
|
|
|
if (!groups[key]) {
|
|
groups[key] = {
|
|
source: triplet.source.id,
|
|
target: triplet.target.id,
|
|
relations: [],
|
|
relationData: [],
|
|
curveStrength: 0,
|
|
};
|
|
}
|
|
groups[key].relations.push(triplet.relation.value);
|
|
groups[key].relationData.push(triplet.relation);
|
|
return groups;
|
|
},
|
|
{} as Record<
|
|
string,
|
|
{
|
|
source: string;
|
|
target: string;
|
|
relations: string[];
|
|
relationData: IdValue[];
|
|
curveStrength: number;
|
|
}
|
|
>,
|
|
);
|
|
|
|
return {
|
|
nodes,
|
|
links: Object.values(linkGroups),
|
|
};
|
|
}, [triplets]);
|
|
|
|
// Initialize or update visualization - This will run only once on mount
|
|
useEffect(() => {
|
|
// Skip if already initialized or ref not available
|
|
if (isInitializedRef.current || !svgRef.current) return;
|
|
|
|
// Mark as initialized to prevent re-running
|
|
isInitializedRef.current = true;
|
|
|
|
const svgElement = d3.select<SVGSVGElement, unknown>(svgRef.current);
|
|
svgElement.selectAll("*").remove();
|
|
|
|
const g = svgElement.append("g");
|
|
|
|
// Drag handler function
|
|
const drag = (
|
|
simulation: d3.Simulation<d3.SimulationNodeDatum, undefined>,
|
|
) => {
|
|
const originalSettings = {
|
|
velocityDecay: 0.4,
|
|
alphaDecay: 0.05,
|
|
};
|
|
|
|
function dragstarted(event: any) {
|
|
if (!event.active) {
|
|
simulation
|
|
.velocityDecay(0.7)
|
|
.alphaDecay(0.1)
|
|
.alphaTarget(0.1)
|
|
.restart();
|
|
}
|
|
d3.select(event.sourceEvent.target.parentNode)
|
|
.select("circle")
|
|
.attr("stroke", theme.node.hover)
|
|
.attr("stroke-width", 3);
|
|
|
|
event.subject.fx = event.subject.x;
|
|
event.subject.fy = event.subject.y;
|
|
}
|
|
|
|
function dragged(event: any) {
|
|
event.subject.x = event.x;
|
|
event.subject.y = event.y;
|
|
event.subject.fx = event.x;
|
|
event.subject.fy = event.y;
|
|
}
|
|
|
|
function dragended(event: any) {
|
|
if (!event.active) {
|
|
simulation
|
|
.velocityDecay(originalSettings.velocityDecay)
|
|
.alphaDecay(originalSettings.alphaDecay)
|
|
.alphaTarget(0);
|
|
}
|
|
|
|
// Keep the node fixed at its final position
|
|
event.subject.fx = event.x;
|
|
event.subject.fy = event.y;
|
|
|
|
d3.select(event.sourceEvent.target.parentNode)
|
|
.select("circle")
|
|
.attr("stroke", theme.node.stroke)
|
|
.attr("stroke-width", 2);
|
|
}
|
|
|
|
return d3
|
|
.drag<any, any>()
|
|
.on("start", dragstarted)
|
|
.on("drag", dragged)
|
|
.on("end", dragended);
|
|
};
|
|
|
|
// Setup zoom behavior
|
|
const zoom = d3
|
|
.zoom<SVGSVGElement, unknown>()
|
|
.scaleExtent([0.1, 4])
|
|
.on("zoom", (event) => {
|
|
g.attr("transform", event.transform);
|
|
});
|
|
|
|
zoomRef.current = zoom;
|
|
// @ts-ignore
|
|
svgElement.call(zoom).call(zoom.transform, d3.zoomIdentity.scale(0.8));
|
|
|
|
// Identify which nodes are isolated (not in any links)
|
|
const nodeIdSet = new Set(nodes.map((n: any) => n.id));
|
|
const linkedNodeIds = new Set<string>();
|
|
|
|
links.forEach((link: any) => {
|
|
const sourceId =
|
|
typeof link.source === "string" ? link.source : link.source.id;
|
|
const targetId =
|
|
typeof link.target === "string" ? link.target : link.target.id;
|
|
linkedNodeIds.add(sourceId);
|
|
linkedNodeIds.add(targetId);
|
|
});
|
|
|
|
// Nodes that don't appear in any link are isolated
|
|
const isolatedNodeIds = new Set<string>();
|
|
nodeIdSet.forEach((nodeId: string) => {
|
|
if (!linkedNodeIds.has(nodeId)) {
|
|
isolatedNodeIds.add(nodeId);
|
|
}
|
|
});
|
|
|
|
// Create simulation with custom forces
|
|
const simulation = d3
|
|
.forceSimulation(nodes as d3.SimulationNodeDatum[])
|
|
.force(
|
|
"link",
|
|
d3
|
|
.forceLink(links)
|
|
.id((d: any) => d.id)
|
|
.distance(200)
|
|
.strength(0.2),
|
|
)
|
|
.force(
|
|
"charge",
|
|
d3
|
|
.forceManyBody()
|
|
.strength((d: any) => {
|
|
// Use a less negative strength for isolated nodes
|
|
// to pull them closer to the center
|
|
return isolatedNodeIds.has(d.id) ? -500 : -3000;
|
|
})
|
|
.distanceMin(20)
|
|
.distanceMax(500)
|
|
.theta(0.8),
|
|
)
|
|
.force("center", d3.forceCenter(width / 2, height / 2).strength(0.05))
|
|
.force(
|
|
"collide",
|
|
d3.forceCollide().radius(50).strength(0.3).iterations(5),
|
|
)
|
|
// Add a special gravity force for isolated nodes to pull them toward the center
|
|
.force(
|
|
"isolatedGravity",
|
|
d3
|
|
.forceRadial(
|
|
100, // distance from center
|
|
width / 2, // center x
|
|
height / 2, // center y
|
|
)
|
|
.strength((d: any) => (isolatedNodeIds.has(d.id) ? 0.15 : 0.01)),
|
|
)
|
|
.velocityDecay(0.4)
|
|
.alphaDecay(0.05)
|
|
.alphaMin(0.001);
|
|
|
|
simulationRef.current = simulation;
|
|
|
|
const link = g.append("g").selectAll("g").data(links).join("g");
|
|
|
|
// Define reset functions
|
|
resetLinksRef.current = () => {
|
|
// @ts-ignore
|
|
link
|
|
.selectAll("path")
|
|
.attr("stroke", theme.link.stroke)
|
|
.attr("stroke-opacity", 0.6)
|
|
.attr("stroke-width", 1);
|
|
|
|
// @ts-ignore
|
|
link.selectAll(".link-label rect").attr("fill", theme.link.label.bg);
|
|
// @ts-ignore
|
|
link.selectAll(".link-label text").attr("fill", theme.link.label.text);
|
|
};
|
|
|
|
// Create node groups
|
|
const node = g
|
|
.append("g")
|
|
.selectAll("g")
|
|
.data(nodes)
|
|
.join("g")
|
|
// @ts-ignore
|
|
.call(drag(simulation))
|
|
.attr("cursor", "pointer");
|
|
|
|
resetNodesRef.current = () => {
|
|
// @ts-ignore
|
|
node
|
|
.selectAll("circle")
|
|
.attr("fill", (d: any) => getNodeColor(d))
|
|
.attr("stroke", theme.node.stroke)
|
|
.attr("stroke-width", 1);
|
|
};
|
|
|
|
// Handle link click
|
|
handleLinkClickRef.current = (event: any, d: any, relation: IdValue) => {
|
|
if (event.stopPropagation) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
if (resetLinksRef.current) resetLinksRef.current();
|
|
if (onEdgeClick) onEdgeClick(relation.id);
|
|
|
|
// Reset all elements to default state
|
|
// @ts-ignore
|
|
link
|
|
.selectAll("path")
|
|
.attr("stroke", theme.link.stroke)
|
|
.attr("stroke-opacity", 0.6)
|
|
.attr("stroke-width", 1);
|
|
|
|
// Reset non-highlighted nodes to their proper colors
|
|
// @ts-ignore
|
|
node
|
|
.selectAll("circle")
|
|
.attr("fill", (d: any) => getNodeColor(d))
|
|
.attr("stroke", theme.node.stroke)
|
|
.attr("stroke-width", 1);
|
|
|
|
// Find and highlight the corresponding path and label
|
|
const linkGroup = event.target?.closest("g")
|
|
? d3.select(event.target.closest("g"))
|
|
: link.filter((l: any) => l === d);
|
|
|
|
// @ts-ignore
|
|
linkGroup
|
|
// @ts-ignore
|
|
.selectAll("path")
|
|
.attr("stroke", theme.link.selected)
|
|
.attr("stroke-opacity", 1)
|
|
.attr("stroke-width", 2);
|
|
|
|
// Update label styling
|
|
// @ts-ignore
|
|
linkGroup.select(".link-label rect").attr("fill", theme.link.selected);
|
|
// @ts-ignore
|
|
linkGroup.select(".link-label text").attr("fill", theme.node.text);
|
|
|
|
// Highlight connected nodes
|
|
// @ts-ignore
|
|
node
|
|
.selectAll("circle")
|
|
.filter((n: any) => n.id === d.source.id || n.id === d.target.id)
|
|
.attr("fill", theme.node.selected)
|
|
.attr("stroke", theme.node.selected)
|
|
.attr("stroke-width", 2);
|
|
|
|
const sourceNode = d.source;
|
|
const targetNode = d.target;
|
|
|
|
// Calculate bounding box for the two connected nodes and the edge
|
|
if (
|
|
sourceNode &&
|
|
targetNode &&
|
|
sourceNode.x !== undefined &&
|
|
targetNode.x !== undefined
|
|
) {
|
|
const padding = 100; // Increased padding for better view
|
|
const minX = Math.min(sourceNode.x, targetNode.x) - padding;
|
|
const minY = Math.min(sourceNode.y, targetNode.y) - padding;
|
|
const maxX = Math.max(sourceNode.x, targetNode.x) + padding;
|
|
const maxY = Math.max(sourceNode.y, targetNode.y) + padding;
|
|
|
|
// Calculate transform to fit the connected nodes
|
|
const boundWidth = maxX - minX;
|
|
const boundHeight = maxY - minY;
|
|
const scale =
|
|
0.9 * Math.min(width / boundWidth, height / boundHeight);
|
|
const midX = (minX + maxX) / 2;
|
|
const midY = (minY + maxY) / 2;
|
|
|
|
if (
|
|
isFinite(scale) &&
|
|
isFinite(midX) &&
|
|
isFinite(midY) &&
|
|
zoomRef.current
|
|
) {
|
|
const transform = d3.zoomIdentity
|
|
.translate(width / 2 - midX * scale, height / 2 - midY * scale)
|
|
.scale(scale);
|
|
|
|
// Animate transition to new view
|
|
// @ts-ignore
|
|
svgElement
|
|
.transition()
|
|
.duration(750)
|
|
.ease(d3.easeCubicInOut) // Add easing for smoother transitions
|
|
.call(zoomRef.current.transform, transform);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Create links with proper curve paths
|
|
link.each(function (d: any) {
|
|
const linkGroup = d3.select(this);
|
|
const relationCount = d.relations.length;
|
|
|
|
// Calculate curve strengths based on number of relations
|
|
const baseStrength = 0.2;
|
|
const strengthStep =
|
|
relationCount > 1 ? baseStrength / (relationCount - 1) : 0;
|
|
|
|
d.relations.forEach((relation: string, index: number) => {
|
|
const curveStrength =
|
|
relationCount > 1 ? -baseStrength + index * strengthStep * 2 : 0;
|
|
const fullRelation = d.relationData[index];
|
|
|
|
linkGroup
|
|
.append("path")
|
|
.attr("stroke", theme.link.stroke)
|
|
.attr("stroke-opacity", 0.6)
|
|
.attr("stroke-width", 1)
|
|
.attr("fill", "none")
|
|
.attr("data-curve-strength", curveStrength)
|
|
.attr("cursor", "pointer")
|
|
.attr(
|
|
"data-source",
|
|
typeof d.source === "object" ? d.source.id : d.source,
|
|
)
|
|
.attr(
|
|
"data-target",
|
|
typeof d.target === "object" ? d.target.id : d.target,
|
|
)
|
|
.on("click", (event) => {
|
|
if (handleLinkClickRef.current) {
|
|
handleLinkClickRef.current(event, d, fullRelation);
|
|
}
|
|
});
|
|
|
|
const labelGroup = linkGroup
|
|
.append("g")
|
|
.attr("class", "link-label")
|
|
.attr("cursor", "pointer")
|
|
.attr("data-curve-strength", curveStrength)
|
|
.on("click", (event) => {
|
|
if (handleLinkClickRef.current) {
|
|
handleLinkClickRef.current(event, d, fullRelation);
|
|
}
|
|
});
|
|
|
|
labelGroup
|
|
.append("rect")
|
|
.attr("fill", theme.link.label.bg)
|
|
.attr("rx", 4)
|
|
.attr("ry", 4)
|
|
.attr("opacity", 0.9);
|
|
|
|
labelGroup
|
|
.append("text")
|
|
.attr("fill", theme.link.label.text)
|
|
.attr("font-size", "8px")
|
|
.attr("text-anchor", "middle")
|
|
.attr("dominant-baseline", "middle")
|
|
.attr("pointer-events", "none")
|
|
.text(relation);
|
|
|
|
labelGroup.attr("data-curve-strength", curveStrength);
|
|
});
|
|
});
|
|
|
|
// Create node circles
|
|
node
|
|
.append("circle")
|
|
.attr("r", 10)
|
|
.attr("fill", (d: any) => getNodeColor(d))
|
|
.attr("stroke", theme.node.stroke)
|
|
.attr("stroke-width", 1)
|
|
.attr("filter", "drop-shadow(0 2px 4px rgba(0,0,0,0.2))")
|
|
.attr("data-id", (d: any) => d.id)
|
|
.attr("cursor", "pointer");
|
|
|
|
// Add node labels
|
|
node
|
|
.append("text")
|
|
.attr("x", 15)
|
|
.attr("y", "0.3em")
|
|
.attr("text-anchor", "start")
|
|
.attr("fill", theme.node.text)
|
|
.attr("font-weight", "500")
|
|
.attr("font-size", "12px")
|
|
.text((d: any) => d.value)
|
|
.attr("cursor", "pointer");
|
|
|
|
// Handle node clicks
|
|
function handleNodeClick(event: any, d: any) {
|
|
event.stopPropagation(); // Ensure the event doesn't bubble up
|
|
|
|
if (resetLinksRef.current) resetLinksRef.current();
|
|
if (resetNodesRef.current) resetNodesRef.current();
|
|
|
|
const selectedNodeId = d?.id;
|
|
|
|
if (selectedNodeId && onNodeClick) {
|
|
onNodeClick(selectedNodeId);
|
|
|
|
// Highlight the selected node
|
|
// @ts-ignore
|
|
node
|
|
.selectAll("circle")
|
|
.filter((n: any) => n.id === selectedNodeId)
|
|
.attr("fill", theme.node.selected)
|
|
.attr("stroke", theme.node.selected)
|
|
.attr("stroke-width", 2);
|
|
|
|
// Find connected nodes and links
|
|
const connectedLinks: any[] = [];
|
|
const connectedNodes = new Set<any>();
|
|
|
|
// Add the selected node to the connected nodes
|
|
const selectedNode = nodes.find((n: any) => n.id === selectedNodeId);
|
|
if (selectedNode) {
|
|
connectedNodes.add(selectedNode);
|
|
}
|
|
|
|
// @ts-ignore
|
|
link.selectAll("path").each(function () {
|
|
const path = d3.select(this);
|
|
const source = path.attr("data-source");
|
|
const target = path.attr("data-target");
|
|
|
|
if (source === selectedNodeId || target === selectedNodeId) {
|
|
const sourceNode = nodes.find((n: any) => n.id === source);
|
|
const targetNode = nodes.find((n: any) => n.id === target);
|
|
|
|
if (sourceNode && targetNode) {
|
|
connectedLinks.push({ source: sourceNode, target: targetNode });
|
|
connectedNodes.add(sourceNode);
|
|
connectedNodes.add(targetNode);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Calculate bounding box of connected nodes
|
|
if (connectedNodes.size > 0 && zoomRef.current) {
|
|
let minX = Infinity,
|
|
minY = Infinity;
|
|
let maxX = -Infinity,
|
|
maxY = -Infinity;
|
|
|
|
connectedNodes.forEach((node: any) => {
|
|
if (node.x !== undefined && node.y !== undefined) {
|
|
minX = Math.min(minX, node.x);
|
|
minY = Math.min(minY, node.y);
|
|
maxX = Math.max(maxX, node.x);
|
|
maxY = Math.max(maxY, node.y);
|
|
}
|
|
});
|
|
|
|
// Add padding
|
|
const padding = 50;
|
|
minX -= padding;
|
|
minY -= padding;
|
|
maxX += padding;
|
|
maxY += padding;
|
|
|
|
// Calculate transform to fit connected nodes
|
|
const boundWidth = maxX - minX;
|
|
const boundHeight = maxY - minY;
|
|
const scale =
|
|
0.9 * Math.min(width / boundWidth, height / boundHeight);
|
|
const midX = (minX + maxX) / 2;
|
|
const midY = (minY + maxY) / 2;
|
|
|
|
if (isFinite(scale) && isFinite(midX) && isFinite(midY)) {
|
|
const transform = d3.zoomIdentity
|
|
.translate(width / 2 - midX * scale, height / 2 - midY * scale)
|
|
.scale(scale);
|
|
|
|
// Animate transition to new view
|
|
// @ts-ignore
|
|
svgElement
|
|
.transition()
|
|
.duration(750)
|
|
.ease(d3.easeCubicInOut) // Add easing for smoother transitions
|
|
.call(zoomRef.current.transform, transform);
|
|
}
|
|
}
|
|
|
|
// Highlight connected links
|
|
// @ts-ignore
|
|
link
|
|
.selectAll("path")
|
|
.attr("stroke", theme.link.stroke)
|
|
.attr("stroke-opacity", 0.6)
|
|
.attr("stroke-width", 1)
|
|
.filter(function () {
|
|
const path = d3.select(this);
|
|
return (
|
|
path.attr("data-source") === selectedNodeId ||
|
|
path.attr("data-target") === selectedNodeId
|
|
);
|
|
})
|
|
.attr("stroke", themeMode === "dark" ? "#ffffff" : colors.pink[600])
|
|
.attr("stroke-width", 2);
|
|
}
|
|
}
|
|
|
|
// Attach click handler to nodes
|
|
node.on("click", handleNodeClick);
|
|
|
|
// Store a reference to the current SVG element
|
|
const svgRefCurrent = svgRef.current;
|
|
|
|
// Add blur handler
|
|
svgElement.on("click", function (event) {
|
|
// Make sure we only handle clicks directly on the SVG element, not on its children
|
|
if (event.target === svgRefCurrent) {
|
|
if (onBlur) onBlur();
|
|
if (resetLinksRef.current) resetLinksRef.current();
|
|
if (resetNodesRef.current) resetNodesRef.current();
|
|
}
|
|
});
|
|
|
|
// Update positions on simulation tick
|
|
simulation.on("tick", () => {
|
|
// Update link paths and labels
|
|
link.each(function (d: any) {
|
|
// Make sure d.source and d.target have x and y properties
|
|
if (!d.source.x && typeof d.source === "string") {
|
|
const sourceNode = nodes.find((n: any) => n.id === d.source);
|
|
// @ts-ignore - Node will have x,y properties from d3 simulation
|
|
if (sourceNode && sourceNode.x) {
|
|
d.source = sourceNode;
|
|
}
|
|
}
|
|
|
|
if (!d.target.x && typeof d.target === "string") {
|
|
const targetNode = nodes.find((n: any) => n.id === d.target);
|
|
// @ts-ignore - Node will have x,y properties from d3 simulation
|
|
if (targetNode && targetNode.x) {
|
|
d.target = targetNode;
|
|
}
|
|
}
|
|
|
|
const linkGroup = d3.select(this);
|
|
linkGroup.selectAll("path").each(function () {
|
|
const path = d3.select(this);
|
|
const curveStrength = +path.attr("data-curve-strength") || 0;
|
|
|
|
// Handle self-referencing nodes
|
|
if (d.source.id === d.target.id) {
|
|
// Create an elliptical path for self-references
|
|
const radiusX = 40;
|
|
const radiusY = 90;
|
|
const offset = radiusY + 20;
|
|
|
|
const cx = d.source.x;
|
|
const cy = d.source.y - offset;
|
|
const path_d = `M${d.source.x},${d.source.y}
|
|
C${cx - radiusX},${cy}
|
|
${cx + radiusX},${cy}
|
|
${d.source.x},${d.source.y}`;
|
|
path.attr("d", path_d);
|
|
|
|
// Position the label
|
|
// @ts-ignore
|
|
const labelGroup = linkGroup
|
|
.selectAll(".link-label")
|
|
.filter(function () {
|
|
return (
|
|
d3.select(this).attr("data-curve-strength") ===
|
|
String(curveStrength)
|
|
);
|
|
});
|
|
|
|
// Update both the group position and the rectangle/text within it
|
|
labelGroup.attr("transform", `translate(${cx}, ${cy - 10})`);
|
|
|
|
// Update the rectangle and text positioning
|
|
// @ts-ignore
|
|
const text = labelGroup.select("text");
|
|
// @ts-ignore
|
|
const rect = labelGroup.select("rect");
|
|
const textBBox = (text.node() as SVGTextElement)?.getBBox();
|
|
|
|
if (textBBox) {
|
|
rect
|
|
.attr("x", -textBBox.width / 2 - 6)
|
|
.attr("y", -textBBox.height / 2 - 4)
|
|
.attr("width", textBBox.width + 12)
|
|
.attr("height", textBBox.height + 8);
|
|
|
|
text.attr("x", 0).attr("y", 0);
|
|
}
|
|
} else {
|
|
const dx = d.target.x - d.source.x;
|
|
const dy = d.target.y - d.source.y;
|
|
const dr = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
const midX = (d.source.x + d.target.x) / 2;
|
|
const midY = (d.source.y + d.target.y) / 2;
|
|
const normalX = -dy / dr;
|
|
const normalY = dx / dr;
|
|
const curveMagnitude = dr * curveStrength;
|
|
const controlX = midX + normalX * curveMagnitude;
|
|
const controlY = midY + normalY * curveMagnitude;
|
|
|
|
const path_d = `M${d.source.x},${d.source.y} Q${controlX},${controlY} ${d.target.x},${d.target.y}`;
|
|
path.attr("d", path_d);
|
|
|
|
const pathNode = path.node() as SVGPathElement;
|
|
if (pathNode) {
|
|
const pathLength = pathNode.getTotalLength();
|
|
const midPoint = pathNode.getPointAtLength(pathLength / 2);
|
|
|
|
// @ts-ignore - Intentionally ignoring d3 selection type issues as in the Svelte version
|
|
const labelGroup = linkGroup
|
|
.selectAll(".link-label")
|
|
.filter(function () {
|
|
return (
|
|
d3.select(this).attr("data-curve-strength") ===
|
|
String(curveStrength)
|
|
);
|
|
});
|
|
|
|
if (midPoint) {
|
|
// @ts-ignore - Intentionally ignoring d3 selection type issues as in the Svelte version
|
|
const text = labelGroup.select("text");
|
|
// @ts-ignore - Intentionally ignoring d3 selection type issues as in the Svelte version
|
|
const rect = labelGroup.select("rect");
|
|
const textBBox = (text.node() as SVGTextElement)?.getBBox();
|
|
|
|
if (textBBox) {
|
|
const angle =
|
|
(Math.atan2(
|
|
d.target.y - d.source.y,
|
|
d.target.x - d.source.x,
|
|
) *
|
|
180) /
|
|
Math.PI;
|
|
const rotationAngle =
|
|
angle > 90 || angle < -90 ? angle - 180 : angle;
|
|
|
|
labelGroup.attr(
|
|
"transform",
|
|
`translate(${midPoint.x}, ${midPoint.y}) rotate(${rotationAngle})`,
|
|
);
|
|
|
|
rect
|
|
.attr("x", -textBBox.width / 2 - 6)
|
|
.attr("y", -textBBox.height / 2 - 4)
|
|
.attr("width", textBBox.width + 12)
|
|
.attr("height", textBBox.height + 8);
|
|
|
|
text.attr("x", 0).attr("y", 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Update node positions
|
|
node.attr("transform", (d: any) => `translate(${d.x},${d.y})`);
|
|
});
|
|
|
|
// Handle zoom-to-fit on mount
|
|
let hasInitialized = false;
|
|
|
|
simulation.on("end", () => {
|
|
if (hasInitialized || !zoomOnMount || !zoomRef.current) return;
|
|
hasInitialized = true;
|
|
|
|
const bounds = g.node()?.getBBox();
|
|
if (bounds) {
|
|
const fullWidth = width;
|
|
const fullHeight = height;
|
|
const currentWidth = bounds.width || 1;
|
|
const currentHeight = bounds.height || 1;
|
|
|
|
// Only proceed if we have valid dimensions
|
|
if (
|
|
currentWidth > 0 &&
|
|
currentHeight > 0 &&
|
|
fullWidth > 0 &&
|
|
fullHeight > 0
|
|
) {
|
|
const midX = bounds.x + currentWidth / 2;
|
|
const midY = bounds.y + currentHeight / 2;
|
|
|
|
// Calculate scale to fit with padding
|
|
const scale =
|
|
0.8 *
|
|
Math.min(fullWidth / currentWidth, fullHeight / currentHeight);
|
|
|
|
// Ensure we have valid numbers before creating transform
|
|
if (isFinite(midX) && isFinite(midY) && isFinite(scale)) {
|
|
const transform = d3.zoomIdentity
|
|
.translate(
|
|
fullWidth / 2 - midX * scale,
|
|
fullHeight / 2 - midY * scale,
|
|
)
|
|
.scale(scale);
|
|
|
|
// Smoothly animate to the new transform
|
|
// @ts-ignore
|
|
svgElement
|
|
.transition()
|
|
.duration(750)
|
|
.ease(d3.easeCubicInOut) // Add easing for smoother transitions
|
|
.call(zoomRef.current.transform, transform);
|
|
} else {
|
|
console.warn("Invalid transform values:", { midX, midY, scale });
|
|
// Fallback to a simple center transform
|
|
const transform = d3.zoomIdentity
|
|
.translate(fullWidth / 2, fullHeight / 2)
|
|
.scale(0.8);
|
|
svgElement.call(zoomRef.current.transform, transform);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Cleanup function - only called when component unmounts
|
|
return () => {
|
|
simulation.stop();
|
|
// Save the ref to a variable before using it in cleanup
|
|
const currentSvgRef = svgRef.current;
|
|
if (currentSvgRef) {
|
|
d3.select(currentSvgRef).on("click", null);
|
|
}
|
|
isInitializedRef.current = false;
|
|
};
|
|
// We're keeping the dependency array empty to ensure initialization runs only once on mount
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// This effect updates the graph theme colors when the theme changes
|
|
useEffect(() => {
|
|
// Skip if not initialized
|
|
if (!svgRef.current || !isInitializedRef.current) return;
|
|
|
|
const svgElement = d3.select(svgRef.current);
|
|
|
|
// Update background
|
|
svgElement.style("background-color", "var(--background-3)");
|
|
|
|
// Update nodes - use getNodeColor for proper color assignment
|
|
svgElement
|
|
.selectAll("circle")
|
|
.attr("fill", (d: any) => getNodeColor(d))
|
|
.attr("stroke", theme.node.stroke);
|
|
|
|
// Update node labels
|
|
// @ts-ignore
|
|
svgElement.selectAll("text").attr("fill", theme.node.text);
|
|
|
|
// Update links
|
|
// @ts-ignore
|
|
svgElement
|
|
.selectAll("path")
|
|
.attr("stroke", theme.link.stroke)
|
|
.attr("stroke-opacity", 0.6);
|
|
|
|
// Update selected links if any
|
|
// @ts-ignore
|
|
svgElement
|
|
.selectAll("path.selected")
|
|
.attr("stroke", theme.link.selected)
|
|
.attr("stroke-opacity", 1);
|
|
|
|
// Update link labels
|
|
// @ts-ignore
|
|
svgElement
|
|
.selectAll(".link-label rect")
|
|
.attr("fill", theme.link.label.bg);
|
|
// @ts-ignore
|
|
svgElement
|
|
.selectAll(".link-label text")
|
|
.attr("fill", theme.link.label.text);
|
|
|
|
// This effect has many dependencies that would cause frequent re-renders
|
|
// We're disabling the exhaustive deps rule to prevent unnecessary re-renders
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [themeMode]);
|
|
|
|
return (
|
|
<svg
|
|
ref={svgRef}
|
|
width={width}
|
|
height={height}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
style={{
|
|
width: "100%",
|
|
height: `${height}px`,
|
|
backgroundColor: theme.background,
|
|
borderRadius: "8px",
|
|
cursor: "grab",
|
|
}}
|
|
/>
|
|
);
|
|
},
|
|
);
|