clone of repo on github
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

255 lines
7.8 KiB

/**
* D3 Force Simulation Utilities
*
* This module provides utilities for creating and managing D3 force-directed
* graph simulations for the event network visualization.
*/
import type { NetworkNode, NetworkLink } from "../types";
import * as d3 from "d3";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const GRAVITY_STRENGTH = 0.05; // Strength of global gravity
const CONNECTED_GRAVITY_STRENGTH = 0.3; // Strength of gravity between connected nodes
/**
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[ForceSimulation]", ...args);
}
}
/**
* Type definition for D3 force simulation
* Provides type safety for simulation operations
*/
export interface Simulation<NodeType, LinkType> {
nodes(): NodeType[];
nodes(nodes: NodeType[]): this;
alpha(): number;
alpha(alpha: number): this;
alphaTarget(): number;
alphaTarget(target: number): this;
restart(): this;
stop(): this;
tick(): this;
on(type: string, listener: (this: this) => void): this;
force(name: string): any;
force(name: string, force: any): this;
}
/**
* Type definition for D3 drag events
* Provides type safety for drag operations
*/
export interface D3DragEvent<GElement extends Element, Datum, Subject> {
active: number;
sourceEvent: any;
subject: Subject;
x: number;
y: number;
dx: number;
dy: number;
identifier: string | number;
}
/**
* Updates a node's velocity by applying a force
*
* @param node - The node to update
* @param deltaVx - Change in x velocity
* @param deltaVy - Change in y velocity
*/
export function updateNodeVelocity(
node: NetworkNode,
deltaVx: number,
deltaVy: number
) {
debug("Updating node velocity", {
nodeId: node.id,
currentVx: node.vx,
currentVy: node.vy,
deltaVx,
deltaVy
});
if (typeof node.vx === "number" && typeof node.vy === "number") {
node.vx = node.vx - deltaVx;
node.vy = node.vy - deltaVy;
debug("New velocity", { nodeId: node.id, vx: node.vx, vy: node.vy });
} else {
debug("Node velocity not defined", { nodeId: node.id });
}
}
/**
* Applies a logarithmic gravity force pulling the node toward the center
*
* The logarithmic scale ensures that nodes far from the center experience
* stronger gravity, preventing them from drifting too far away.
*
* @param node - The node to apply gravity to
* @param centerX - X coordinate of the center
* @param centerY - Y coordinate of the center
* @param alpha - Current simulation alpha (cooling factor)
*/
export function applyGlobalLogGravity(
node: NetworkNode,
centerX: number,
centerY: number,
alpha: number,
) {
// Tag anchors and person anchors should not be affected by gravity
if (node.isTagAnchor || node.isPersonAnchor) return;
const dx = (node.x ?? 0) - centerX;
const dy = (node.y ?? 0) - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const force = Math.log(distance + 1) * GRAVITY_STRENGTH * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
/**
* Applies gravity between connected nodes
*
* This creates a cohesive force that pulls connected nodes toward their
* collective center of gravity, creating more meaningful clusters.
*
* @param node - The node to apply connected gravity to
* @param links - All links in the network
* @param alpha - Current simulation alpha (cooling factor)
*/
export function applyConnectedGravity(
node: NetworkNode,
links: NetworkLink[],
alpha: number,
) {
// Tag anchors and person anchors should not be affected by connected gravity
if (node.isTagAnchor || node.isPersonAnchor) return;
// Find all nodes connected to this node (excluding tag anchors and person anchors)
const connectedNodes = links
.filter(link => link.source.id === node.id || link.target.id === node.id)
.map(link => link.source.id === node.id ? link.target : link.source)
.filter(n => !n.isTagAnchor && !n.isPersonAnchor);
if (connectedNodes.length === 0) return;
// Calculate center of gravity of connected nodes
const cogX = d3.mean(connectedNodes, (n: NetworkNode) => n.x);
const cogY = d3.mean(connectedNodes, (n: NetworkNode) => n.y);
if (cogX === undefined || cogY === undefined) return;
// Calculate force direction and magnitude
const dx = (node.x ?? 0) - cogX;
const dy = (node.y ?? 0) - cogY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
// Apply force proportional to distance
const force = distance * CONNECTED_GRAVITY_STRENGTH * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
/**
* Sets up drag behavior for nodes
*
* This enables interactive dragging of nodes in the visualization.
*
* @param simulation - The D3 force simulation
* @param warmupClickEnergy - Alpha target when dragging starts (0-1)
* @returns D3 drag behavior configured for the simulation
*/
export function setupDragHandlers(
simulation: Simulation<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9
) {
return d3
.drag()
.on("start", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Tag anchors and person anchors retain their anchor behavior
if (d.isTagAnchor || d.isPersonAnchor) {
// Still allow dragging but maintain anchor status
d.fx = d.x;
d.fy = d.y;
return;
}
// Warm up simulation if it's cooled down
if (!event.active) {
simulation.alphaTarget(warmupClickEnergy).restart();
}
// Fix node position at current location
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Update position for all nodes including anchors
// Update fixed position to mouse position
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Cool down simulation when drag ends
if (!event.active) {
simulation.alphaTarget(0);
}
// Keep all nodes fixed after dragging
// This allows users to manually position any node type
d.fx = d.x;
d.fy = d.y;
});
}
/**
* Creates a D3 force simulation for the network
*
* @param nodes - Array of network nodes
* @param links - Array of network links
* @param nodeRadius - Radius of node circles
* @param linkDistance - Desired distance between linked nodes
* @returns Configured D3 force simulation
*/
export function createSimulation(
nodes: NetworkNode[],
links: NetworkLink[],
nodeRadius: number,
linkDistance: number
): Simulation<NetworkNode, NetworkLink> {
debug("Creating simulation", {
nodeCount: nodes.length,
linkCount: links.length,
nodeRadius,
linkDistance
});
try {
// Create the simulation with nodes
const simulation = d3
.forceSimulation(nodes)
.force(
"link",
d3.forceLink(links)
.id((d: NetworkNode) => d.id)
.distance(linkDistance * 0.1)
)
.force("collide", d3.forceCollide().radius(nodeRadius * 4));
debug("Simulation created successfully");
return simulation;
} catch (error) {
console.error("Error creating simulation:", error);
throw error;
}
}