neuronic 0.1.0

Real-time graphical visualization of Caryatid message bus flow
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
//! Graph rendering functions.
//!
//! This module handles all visual rendering of the message flow graph,
//! including nodes, edges, particles, and the minimap.

use crate::graph::{HealthStatus, MessageFlowGraph, ModuleNode};
use egui::{Color32, Pos2, Rect, Stroke, Vec2};
use std::collections::HashMap;

use super::theme::Theme;
use super::types::{NodeActivity, PulseRing, SynapseParticle};

/// Calculate a point on a quadratic Bezier curve.
///
/// # Arguments
///
/// * `p0` - Start point
/// * `p1` - Control point
/// * `p2` - End point
/// * `t` - Parameter in range [0, 1] where 0 = start, 1 = end
///
/// # Returns
///
/// The interpolated position on the curve.
pub fn quadratic_bezier(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 {
    let t = t.clamp(0.0, 1.0);
    let mt = 1.0 - t;
    Pos2::new(
        mt * mt * p0.x + 2.0 * mt * t * p1.x + t * t * p2.x,
        mt * mt * p0.y + 2.0 * mt * t * p1.y + t * t * p2.y,
    )
}

/// Generate evenly-spaced points along a quadratic Bezier curve.
///
/// Used for drawing smooth curved edges between nodes.
///
/// # Arguments
///
/// * `p0` - Start point
/// * `p1` - Control point
/// * `p2` - End point
/// * `segments` - Number of line segments to generate
pub fn bezier_points(p0: Pos2, p1: Pos2, p2: Pos2, segments: usize) -> Vec<Pos2> {
    (0..=segments)
        .map(|i| {
            let t = i as f32 / segments as f32;
            quadratic_bezier(p0, p1, p2, t)
        })
        .collect()
}

/// Calculate the tangent direction at a point on a quadratic Bezier curve.
///
/// Used for orienting arrows and particles along edge curves.
///
/// # Arguments
///
/// * `p0` - Start point
/// * `p1` - Control point
/// * `p2` - End point
/// * `t` - Parameter in range [0, 1]
///
/// # Returns
///
/// A normalized direction vector tangent to the curve at parameter `t`.
pub fn bezier_tangent(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Vec2 {
    let t = t.clamp(0.0, 1.0);
    let mt = 1.0 - t;
    Vec2::new(
        2.0 * mt * (p1.x - p0.x) + 2.0 * t * (p2.x - p1.x),
        2.0 * mt * (p1.y - p0.y) + 2.0 * t * (p2.y - p1.y),
    )
    .normalized()
}

/// Linearly interpolate between two colors.
///
/// # Arguments
///
/// * `a` - Start color (at t=0)
/// * `b` - End color (at t=1)
/// * `t` - Interpolation factor, clamped to [0, 1]
pub fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
    let t = t.clamp(0.0, 1.0);
    Color32::from_rgba_unmultiplied(
        (a.r() as f32 + (b.r() as f32 - a.r() as f32) * t) as u8,
        (a.g() as f32 + (b.g() as f32 - a.g() as f32) * t) as u8,
        (a.b() as f32 + (b.b() as f32 - a.b() as f32) * t) as u8,
        (a.a() as f32 + (b.a() as f32 - a.a() as f32) * t) as u8,
    )
}

/// Format a message rate for display.
///
/// Automatically selects appropriate units (M/s, k/s, /s) based on magnitude.
pub fn format_rate(rate: f64) -> String {
    if rate >= 1_000_000.0 {
        format!("{:.1}M/s", rate / 1_000_000.0)
    } else if rate >= 1_000.0 {
        format!("{:.1}k/s", rate / 1_000.0)
    } else if rate >= 1.0 {
        format!("{:.0}/s", rate)
    } else if rate > 0.0 {
        format!("{:.1}/s", rate)
    } else {
        String::new()
    }
}

/// Determine the display color for a node based on health status and activity.
///
/// Colors transition from base → active → firing as activity increases,
/// with the base color determined by health status (healthy/warning/critical).
pub fn get_neuron_color(
    node: &ModuleNode,
    activity: Option<&NodeActivity>,
    theme: &Theme,
) -> Color32 {
    let fire_intensity = activity.map(|a| a.fire_intensity).unwrap_or(0.0);

    let base = match node.health {
        HealthStatus::Healthy => theme.neuron_base(),
        HealthStatus::Warning => theme.neuron_warning(),
        HealthStatus::Critical => theme.neuron_critical(),
    };

    if fire_intensity > 0.5 {
        lerp_color(
            theme.neuron_active(),
            theme.neuron_firing(),
            (fire_intensity - 0.5) * 2.0,
        )
    } else if fire_intensity > 0.0 {
        lerp_color(base, theme.neuron_active(), fire_intensity * 2.0)
    } else {
        base
    }
}

/// Drawing context with all state needed for rendering.
pub struct DrawContext<'a> {
    pub graph: &'a MessageFlowGraph,
    pub positions: &'a HashMap<String, Pos2>,
    pub activity: &'a HashMap<String, NodeActivity>,
    pub particles: &'a HashMap<(String, String, String), Vec<SynapseParticle>>,
    pub pulse_rings: &'a HashMap<String, Vec<PulseRing>>,
    pub selected_node: Option<&'a String>,
    pub highlighted_node: Option<&'a String>,
    pub theme: &'a Theme,
    pub zoom: f32,
    pub pan: Vec2,
    pub show_labels: bool,
    pub show_gradient_edges: bool,
    pub show_pulse_rings: bool,
}

impl<'a> DrawContext<'a> {
    /// Convert world position to screen position.
    pub fn world_to_screen(&self, world_pos: Pos2, rect: Rect) -> Pos2 {
        let center = rect.center();
        let offset = (world_pos - center) * self.zoom;
        center + offset + self.pan
    }

    /// Draw the complete graph.
    pub fn draw_graph(&self, ui: &mut egui::Ui, rect: Rect) {
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, 0.0, self.theme.background());

        self.draw_edges(&painter, rect);
        self.draw_nodes(&painter, rect);
    }

    fn draw_edges(&self, painter: &egui::Painter, rect: Rect) {
        for edge_idx in self.graph.graph.edge_indices() {
            if let Some((source, target)) = self.graph.graph.edge_endpoints(edge_idx) {
                let source_node = &self.graph.graph[source];
                let target_node = &self.graph.graph[target];
                let edge = &self.graph.graph[edge_idx];

                let world_s = self
                    .positions
                    .get(&source_node.name)
                    .copied()
                    .unwrap_or(rect.center());
                let world_t = self
                    .positions
                    .get(&target_node.name)
                    .copied()
                    .unwrap_or(rect.center());

                let pos_s = self.world_to_screen(world_s, rect);
                let pos_t = self.world_to_screen(world_t, rect);

                let edge_color = match edge.health {
                    HealthStatus::Healthy => self.theme.synapse_base(),
                    HealthStatus::Warning => self.theme.neuron_warning().gamma_multiply(0.7),
                    HealthStatus::Critical => self.theme.neuron_critical().gamma_multiply(0.7),
                };

                let width =
                    (1.0 + (edge.rate.unwrap_or(0.0).log10().max(0.0) as f32) * 0.3) * self.zoom;

                // Calculate Bezier control point
                let mid = pos_s + (pos_t - pos_s) * 0.5;
                let dir = (pos_t - pos_s).normalized();
                let perp = Vec2::new(-dir.y, dir.x);
                let distance = (pos_t - pos_s).length();
                let curve_amount = (distance * 0.15).min(40.0 * self.zoom);
                let control = mid + perp * curve_amount;

                // Draw curved edge
                if self.show_gradient_edges {
                    self.draw_gradient_edge(painter, pos_s, control, pos_t, edge_color, width);
                } else {
                    let segments = 20;
                    let points = bezier_points(pos_s, control, pos_t, segments);
                    for i in 0..points.len() - 1 {
                        painter.line_segment(
                            [points[i], points[i + 1]],
                            Stroke::new(width, edge_color),
                        );
                    }
                }

                // Draw arrow
                let node_radius = 12.0 * self.zoom;
                let arrow_t = 1.0 - (node_radius + 8.0 * self.zoom) / distance.max(1.0);
                let arrow_pos = quadratic_bezier(pos_s, control, pos_t, arrow_t.max(0.8));
                let arrow_dir = bezier_tangent(pos_s, control, pos_t, arrow_t.max(0.8));
                let arrow_perp = Vec2::new(-arrow_dir.y, arrow_dir.x);
                let arrow_size = 5.0 * self.zoom;
                let arrow_points = vec![
                    arrow_pos + arrow_dir * arrow_size,
                    arrow_pos + arrow_perp * arrow_size * 0.4,
                    arrow_pos - arrow_perp * arrow_size * 0.4,
                ];
                painter.add(egui::Shape::convex_polygon(
                    arrow_points,
                    edge_color,
                    Stroke::NONE,
                ));

                // Draw synapse particles
                let key = (
                    source_node.name.clone(),
                    target_node.name.clone(),
                    edge.topic.clone(),
                );
                if let Some(particles) = self.particles.get(&key) {
                    for particle in particles {
                        let particle_pos =
                            quadratic_bezier(pos_s, control, pos_t, particle.progress);
                        let particle_color = self.theme.synapse_active();
                        let glow_radius = 4.0 * self.zoom;
                        painter.circle_filled(
                            particle_pos,
                            glow_radius * 2.0,
                            particle_color.gamma_multiply(0.3),
                        );
                        painter.circle_filled(particle_pos, glow_radius, particle_color);
                    }
                }

                // Topic label
                if self.show_labels && self.zoom > 0.5 {
                    let label_pos = quadratic_bezier(pos_s, control, pos_t, 0.5);
                    painter.text(
                        label_pos,
                        egui::Align2::CENTER_CENTER,
                        &edge.topic,
                        egui::FontId::proportional(9.0 * self.zoom),
                        self.theme.text_secondary(),
                    );
                }
            }
        }
    }

    fn draw_gradient_edge(
        &self,
        painter: &egui::Painter,
        p0: Pos2,
        p1: Pos2,
        p2: Pos2,
        base_color: Color32,
        width: f32,
    ) {
        let segments = 20;
        let points = bezier_points(p0, p1, p2, segments);

        for i in 0..points.len() - 1 {
            let t = i as f32 / segments as f32;
            // Gradient from darker to brighter along the edge
            let brightness = 0.5 + t * 0.5;
            let color = Color32::from_rgba_unmultiplied(
                (base_color.r() as f32 * brightness) as u8,
                (base_color.g() as f32 * brightness) as u8,
                (base_color.b() as f32 * brightness) as u8,
                base_color.a(),
            );
            painter.line_segment([points[i], points[i + 1]], Stroke::new(width, color));
        }
    }

    fn draw_nodes(&self, painter: &egui::Painter, rect: Rect) {
        for node in self.graph.graph.node_weights() {
            let world_pos = self
                .positions
                .get(&node.name)
                .copied()
                .unwrap_or(rect.center());

            let pos = self.world_to_screen(world_pos, rect);

            let base_radius = 12.0 * self.zoom;
            let radius =
                base_radius + (node.throughput() as f32).log10().max(0.0) * 2.0 * self.zoom;

            let activity = self.activity.get(&node.name);
            let color = get_neuron_color(node, activity, self.theme);
            let is_selected = self.selected_node == Some(&node.name);
            let is_highlighted = self.highlighted_node == Some(&node.name);

            let fire_intensity = activity.map(|a| a.fire_intensity).unwrap_or(0.0);

            // Draw pulse rings
            if self.show_pulse_rings {
                if let Some(rings) = self.pulse_rings.get(&node.name) {
                    for ring in rings {
                        let ring_color = self
                            .theme
                            .neuron_active()
                            .gamma_multiply(ring.opacity * 0.5);
                        painter.circle_stroke(
                            pos,
                            ring.radius * self.zoom,
                            Stroke::new(2.0 * self.zoom, ring_color),
                        );
                    }
                }
            }

            // Glow effect for active neurons
            if fire_intensity > 0.1 {
                let glow_radius = radius + (8.0 + fire_intensity * 10.0) * self.zoom;
                let glow_color = self
                    .theme
                    .neuron_active()
                    .gamma_multiply(fire_intensity * 0.4);
                painter.circle_filled(pos, glow_radius, glow_color);
            }

            // Main neuron body
            painter.circle_filled(pos, radius, color);

            // Inner highlight (nucleus)
            let nucleus_color = lerp_color(color, Color32::WHITE, 0.3);
            painter.circle_filled(pos, radius * 0.4, nucleus_color);

            // Selection/highlight ring
            if is_selected || is_highlighted {
                let ring_color = if is_highlighted {
                    Color32::YELLOW
                } else {
                    Color32::WHITE
                };
                painter.circle_stroke(pos, radius + 4.0 * self.zoom, Stroke::new(2.0, ring_color));
            }

            // Node label
            if self.zoom > 0.4 {
                painter.text(
                    pos + Vec2::new(0.0, radius + 10.0 * self.zoom),
                    egui::Align2::CENTER_CENTER,
                    &node.name,
                    egui::FontId::proportional(10.0 * self.zoom),
                    self.theme.text_primary(),
                );

                if let Some(rate) = node.rate() {
                    let rate_text = format_rate(rate);
                    painter.text(
                        pos + Vec2::new(0.0, radius + 22.0 * self.zoom),
                        egui::Align2::CENTER_CENTER,
                        &rate_text,
                        egui::FontId::proportional(8.0 * self.zoom),
                        self.theme.text_secondary(),
                    );
                }
            }
        }
    }
}

/// Draw a minimap overview in the bottom-right corner.
///
/// Shows all nodes as dots with a rectangle indicating the current viewport.
/// Helps with navigation when zoomed in on large graphs.
pub fn draw_minimap(
    ui: &mut egui::Ui,
    _graph: &MessageFlowGraph,
    positions: &HashMap<String, Pos2>,
    viewport_rect: Rect,
    zoom: f32,
    pan: Vec2,
    theme: &Theme,
) {
    let minimap_size = Vec2::new(150.0, 100.0);
    let minimap_rect = Rect::from_min_size(
        Pos2::new(
            viewport_rect.right() - minimap_size.x - 10.0,
            viewport_rect.bottom() - minimap_size.y - 10.0,
        ),
        minimap_size,
    );

    let painter = ui.painter_at(minimap_rect);

    // Background
    painter.rect_filled(minimap_rect, 4.0, theme.panel_fill().gamma_multiply(0.9));
    painter.rect_stroke(
        minimap_rect,
        4.0,
        Stroke::new(1.0, theme.text_secondary()),
        egui::StrokeKind::Outside,
    );

    if positions.is_empty() {
        return;
    }

    // Find bounds of all nodes
    let mut min_x = f32::MAX;
    let mut max_x = f32::MIN;
    let mut min_y = f32::MAX;
    let mut max_y = f32::MIN;

    for pos in positions.values() {
        min_x = min_x.min(pos.x);
        max_x = max_x.max(pos.x);
        min_y = min_y.min(pos.y);
        max_y = max_y.max(pos.y);
    }

    let world_width = (max_x - min_x).max(100.0);
    let world_height = (max_y - min_y).max(100.0);
    let world_center = Pos2::new((min_x + max_x) / 2.0, (min_y + max_y) / 2.0);

    let scale_x = (minimap_size.x - 20.0) / world_width;
    let scale_y = (minimap_size.y - 20.0) / world_height;
    let scale = scale_x.min(scale_y);

    // Draw nodes as dots
    for pos in positions.values() {
        let minimap_pos = Pos2::new(
            minimap_rect.center().x + (pos.x - world_center.x) * scale,
            minimap_rect.center().y + (pos.y - world_center.y) * scale,
        );

        if minimap_rect.contains(minimap_pos) {
            painter.circle_filled(minimap_pos, 2.0, theme.neuron_base());
        }
    }

    // Draw viewport rectangle
    let viewport_center = viewport_rect.center();
    let viewport_world_center = Pos2::new(viewport_center.x - pan.x, viewport_center.y - pan.y);

    let viewport_world_size =
        Vec2::new(viewport_rect.width() / zoom, viewport_rect.height() / zoom);

    let minimap_viewport_center = Pos2::new(
        minimap_rect.center().x + (viewport_world_center.x - world_center.x) * scale,
        minimap_rect.center().y + (viewport_world_center.y - world_center.y) * scale,
    );

    let minimap_viewport_size =
        Vec2::new(viewport_world_size.x * scale, viewport_world_size.y * scale);

    let minimap_viewport_rect =
        Rect::from_center_size(minimap_viewport_center, minimap_viewport_size);

    painter.rect_stroke(
        minimap_viewport_rect,
        2.0,
        Stroke::new(1.0, Color32::WHITE.gamma_multiply(0.5)),
        egui::StrokeKind::Outside,
    );
}