CI / CI (push) Successful in 15m14s
All errors were API mismatches — handler code calling wrong method names, wrong argument types, or missing imports/derives. No logic changes. Build now passes with SQLX_OFFLINE=true. Key fixes: - embed_text -> embed_one, Vector -> Vec<f32> conversion - extract_token: extract auth header from HttpRequest first - AuthError variants aligned to actual enum definition - recursive async fns boxed (dfs_paths in inference + path_finder) - missing derives (Default, Serialize), imports (sqlx::Row, Timelike) - borrow-after-move: compute .len() before struct field move - streaming_body -> streaming with Result<Bytes> for SSE - CI: add SQLX_OFFLINE=true for offline builds without DB 25 files changed, 99 insertions(+), 81 deletions(-) Co-authored-by: rock <[email protected]>
267 lines
8.5 KiB
Rust
267 lines
8.5 KiB
Rust
/// Force-directed layout algorithm for graph visualization.
|
|
///
|
|
/// Uses physics simulation (repulsive + attractive forces) to compute
|
|
/// node positions in 2D space suitable for React Flow visualization.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use super::bfs_graph_traversal::{GraphData, TraversalNode, TraversalEdge};
|
|
|
|
/// 2D position (X, Y coordinates)
|
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
|
pub struct Position {
|
|
pub x: f32,
|
|
pub y: f32,
|
|
}
|
|
|
|
/// Force simulation parameters
|
|
#[derive(Debug, Clone)]
|
|
pub struct LayoutConfig {
|
|
pub iterations: usize, // Number of solver iterations (10-100)
|
|
pub charge: f32, // Repulsive force strength (-500 to -1000)
|
|
pub link_distance: f32, // Ideal edge length (50-150)
|
|
pub alpha_decay: f32, // Cooling rate (0.02-0.10)
|
|
pub width: f32, // Canvas width (default 800)
|
|
pub height: f32, // Canvas height (default 600)
|
|
}
|
|
|
|
impl Default for LayoutConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
iterations: 50,
|
|
charge: -800.0,
|
|
link_distance: 100.0,
|
|
alpha_decay: 0.05,
|
|
width: 800.0,
|
|
height: 600.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Layout result with computed positions
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LayoutResult {
|
|
pub positions: std::collections::HashMap<String, Position>,
|
|
pub iterations_completed: usize,
|
|
pub layout_time_ms: u64,
|
|
}
|
|
|
|
/// Velocity for each node in simulation
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct Velocity {
|
|
vx: f32,
|
|
vy: f32,
|
|
}
|
|
|
|
/// Force-directed layout engine
|
|
pub struct ForceDirectedLayout;
|
|
|
|
impl ForceDirectedLayout {
|
|
/// Compute layout for graph
|
|
pub fn layout(graph: &GraphData, config: &LayoutConfig) -> LayoutResult {
|
|
let start_time = std::time::Instant::now();
|
|
|
|
// Initialize positions randomly in canvas
|
|
let mut positions = Self::initialize_positions(&graph.nodes, config);
|
|
let mut velocities: std::collections::HashMap<String, Velocity> = graph.nodes
|
|
.iter()
|
|
.map(|n| (n.id.clone(), Velocity { vx: 0.0, vy: 0.0 }))
|
|
.collect();
|
|
|
|
// Simulation parameters
|
|
let mut alpha = 1.0;
|
|
let alpha_target = 0.001;
|
|
|
|
// Iterate until convergence
|
|
for iteration in 0..config.iterations {
|
|
// Apply forces
|
|
for node in &graph.nodes {
|
|
let mut fx = 0.0;
|
|
let mut fy = 0.0;
|
|
|
|
let pos = positions.get(&node.id).unwrap();
|
|
|
|
// 1. Repulsive forces (all pairs)
|
|
for other_node in &graph.nodes {
|
|
if node.id == other_node.id {
|
|
continue;
|
|
}
|
|
|
|
let other_pos = positions.get(&other_node.id).unwrap();
|
|
let (dfx, dfy) = Self::repulsive_force(
|
|
*pos,
|
|
*other_pos,
|
|
config.charge,
|
|
);
|
|
fx += dfx;
|
|
fy += dfy;
|
|
}
|
|
|
|
// 2. Attractive forces (linked nodes)
|
|
for edge in &graph.edges {
|
|
if edge.source_id == node.id {
|
|
let target_pos = positions.get(&edge.target_id).unwrap();
|
|
let (dfx, dfy) = Self::attractive_force(
|
|
*pos,
|
|
*target_pos,
|
|
config.link_distance,
|
|
);
|
|
fx += dfx;
|
|
fy += dfy;
|
|
}
|
|
}
|
|
|
|
// Update velocity (with damping)
|
|
let vel = velocities.get_mut(&node.id).unwrap();
|
|
vel.vx += fx * alpha;
|
|
vel.vy += fy * alpha;
|
|
vel.vx *= 0.95; // Damping
|
|
vel.vy *= 0.95;
|
|
}
|
|
|
|
// Update positions
|
|
for node in &graph.nodes {
|
|
let vel = velocities.get(&node.id).unwrap();
|
|
let pos = positions.get_mut(&node.id).unwrap();
|
|
|
|
pos.x += vel.vx;
|
|
pos.y += vel.vy;
|
|
|
|
// Boundary constraints
|
|
pos.x = pos.x.max(0.0).min(config.width);
|
|
pos.y = pos.y.max(0.0).min(config.height);
|
|
}
|
|
|
|
// Cool down (reduce step size)
|
|
alpha *= (alpha_target / alpha).powf(config.alpha_decay);
|
|
|
|
// Early exit if converged
|
|
if alpha < alpha_target {
|
|
return LayoutResult {
|
|
positions,
|
|
iterations_completed: iteration + 1,
|
|
layout_time_ms: start_time.elapsed().as_millis() as u64,
|
|
};
|
|
}
|
|
}
|
|
|
|
LayoutResult {
|
|
positions,
|
|
iterations_completed: config.iterations,
|
|
layout_time_ms: start_time.elapsed().as_millis() as u64,
|
|
}
|
|
}
|
|
|
|
/// Initialize random positions
|
|
fn initialize_positions(
|
|
nodes: &[TraversalNode],
|
|
config: &LayoutConfig,
|
|
) -> std::collections::HashMap<String, Position> {
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
let mut positions = std::collections::HashMap::new();
|
|
|
|
for node in nodes {
|
|
// Pseudo-random based on node ID (deterministic)
|
|
let mut hasher = DefaultHasher::new();
|
|
node.id.hash(&mut hasher);
|
|
let hash = hasher.finish();
|
|
|
|
let x = (hash as f32 % config.width).abs();
|
|
let y = ((hash >> 32) as f32 % config.height).abs();
|
|
|
|
positions.insert(node.id.clone(), Position { x, y });
|
|
}
|
|
|
|
positions
|
|
}
|
|
|
|
/// Coulomb repulsion force
|
|
fn repulsive_force(p1: Position, p2: Position, charge: f32) -> (f32, f32) {
|
|
let dx = p2.x - p1.x;
|
|
let dy = p2.y - p1.y;
|
|
let dist_sq = dx * dx + dy * dy + 1.0; // Add 1 to avoid singularity
|
|
let dist = dist_sq.sqrt();
|
|
|
|
let force = charge / dist_sq;
|
|
let fx = (force * dx / dist);
|
|
let fy = (force * dy / dist);
|
|
|
|
(-fx, -fy) // Negative = repulsive
|
|
}
|
|
|
|
/// Hooke's law attractive force
|
|
fn attractive_force(p1: Position, p2: Position, link_distance: f32) -> (f32, f32) {
|
|
let dx = p2.x - p1.x;
|
|
let dy = p2.y - p1.y;
|
|
let dist = (dx * dx + dy * dy).sqrt().max(0.1);
|
|
|
|
let displacement = dist - link_distance;
|
|
let force = 0.1 * displacement; // Spring constant
|
|
|
|
let fx = (force * dx / dist);
|
|
let fy = (force * dy / dist);
|
|
|
|
(fx, fy) // Positive = attractive
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_layout_config_defaults() {
|
|
let config = LayoutConfig::default();
|
|
assert_eq!(config.iterations, 50);
|
|
assert_eq!(config.width, 800.0);
|
|
assert_eq!(config.height, 600.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_position_creation() {
|
|
let pos = Position { x: 100.0, y: 200.0 };
|
|
assert_eq!(pos.x, 100.0);
|
|
assert_eq!(pos.y, 200.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_repulsive_force() {
|
|
let p1 = Position { x: 0.0, y: 0.0 };
|
|
let p2 = Position { x: 10.0, y: 0.0 };
|
|
|
|
let (fx, fy) = ForceDirectedLayout::repulsive_force(p1, p2, -800.0);
|
|
|
|
// Should push p1 away from p2 (negative x)
|
|
assert!(fx < 0.0);
|
|
assert_eq!(fy, 0.0); // No y component
|
|
}
|
|
|
|
#[test]
|
|
fn test_attractive_force() {
|
|
let p1 = Position { x: 0.0, y: 0.0 };
|
|
let p2 = Position { x: 100.0, y: 0.0 };
|
|
|
|
let (fx, fy) = ForceDirectedLayout::attractive_force(p1, p2, 50.0);
|
|
|
|
// Distance is 100, ideal is 50, so pull p1 towards p2 (positive x)
|
|
assert!(fx > 0.0);
|
|
assert_eq!(fy, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_layout_result_creation() {
|
|
let mut positions = std::collections::HashMap::new();
|
|
positions.insert("n1".to_string(), Position { x: 10.0, y: 20.0 });
|
|
|
|
let result = LayoutResult {
|
|
positions,
|
|
iterations_completed: 25,
|
|
layout_time_ms: 150,
|
|
};
|
|
|
|
assert_eq!(result.iterations_completed, 25);
|
|
assert_eq!(result.layout_time_ms, 150);
|
|
}
|
|
}
|