graph-explorer-render 0.1.0

WebGL2/wgpu renderer for graph-explorer — nodes, edges, halos and labels.
Documentation
use bytemuck::{Pod, Zeroable};

#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct CameraUniform {
    /// world-space center the camera looks at
    pub center: [f32; 2],
    /// zoom (world units per half-viewport shrink factor)
    pub zoom: f32,
    pub fade: f32,          // was _pad0 — global alpha multiplier [0,1]
    /// viewport size in physical pixels
    pub viewport: [f32; 2],
    pub _pad1: [f32; 2],
}

pub struct Camera {
    pub center: [f32; 2],
    pub zoom: f32,
    pub viewport: [f32; 2],
    pub fade: f32,
    pub target_center: [f32; 2],
    pub target_zoom: f32,
    pub gliding: bool,
}

impl Camera {
    pub fn new(width: f32, height: f32) -> Self {
        Self {
            center: [0.0, 0.0],
            zoom: 100.0,
            viewport: [width, height],
            fade: 1.0,
            target_center: [0.0, 0.0],
            target_zoom: 100.0,
            gliding: false,
        }
    }
    pub fn uniform(&self) -> CameraUniform {
        CameraUniform {
            center: self.center,
            zoom: self.zoom,
            fade: self.fade,
            viewport: self.viewport,
            _pad1: [0.0, 0.0],
        }
    }
    pub fn pan_pixels(&mut self, dx: f32, dy: f32) {
        // screen pixels -> world units; screen y is down, world y is up
        self.center[0] -= dx / self.zoom;
        self.center[1] += dy / self.zoom;
    }
    pub fn zoom_by(&mut self, factor: f32) {
        self.zoom = (self.zoom * factor).clamp(0.01, 5000.0);
    }

    /// Center and zoom so the world-space AABB [min, max] fits the viewport with margin.
    pub fn fit_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
        let (c, z) = self.view_for_bounds(min, max);
        self.snap_to(c, z);
    }

    /// Jump immediately to `center`/`zoom` (no glide); clears any glide.
    pub fn snap_to(&mut self, center: [f32; 2], zoom: f32) {
        self.center = center;
        self.zoom = zoom;
        self.target_center = center;
        self.target_zoom = zoom;
        self.gliding = false;
    }

    /// Begin easing toward `center`/`zoom`.
    pub fn glide_to(&mut self, center: [f32; 2], zoom: f32) {
        self.target_center = center;
        self.target_zoom = zoom;
        self.gliding = true;
    }

    /// Advance an in-flight glide by `dt_ms`, using `dur_ms` to set the pace.
    /// Built-in smooth (exponential) ease — no `graph-explorer-style` dependency.
    /// No-op when not gliding; snaps when `dur_ms <= 0`.
    pub fn tick(&mut self, dt_ms: f32, dur_ms: f32) {
        if !self.gliding { return; }
        if dur_ms <= 0.0 {
            self.center = self.target_center;
            self.zoom = self.target_zoom;
            self.gliding = false;
            return;
        }
        // fraction of remaining distance to cover this step
        let k = (1.0 - (-dt_ms / (dur_ms * 0.35)).exp()).clamp(0.0, 1.0);
        self.center[0] += (self.target_center[0] - self.center[0]) * k;
        self.center[1] += (self.target_center[1] - self.center[1]) * k;
        self.zoom += (self.target_zoom - self.zoom) * k;
        let dx = self.target_center[0] - self.center[0];
        let dy = self.target_center[1] - self.center[1];
        if (dx * dx + dy * dy).sqrt() < 0.5 && (self.target_zoom - self.zoom).abs() < 0.5 {
            self.center = self.target_center;
            self.zoom = self.target_zoom;
            self.gliding = false;
        }
    }

    /// Compute the (center, zoom) that frames world-AABB `[min,max]` with margin.
    pub fn view_for_bounds(&self, min: [f32; 2], max: [f32; 2]) -> ([f32; 2], f32) {
        let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
        let half_w = ((max[0] - min[0]) * 0.5).max(1.0);
        let half_h = ((max[1] - min[1]) * 0.5).max(1.0);
        let zoom_x = (self.viewport[0] * 0.5) / half_w;
        let zoom_y = (self.viewport[1] * 0.5) / half_h;
        (center, (zoom_x.min(zoom_y) * 0.9).clamp(0.01, 5000.0))
    }

    /// Glide to frame world-AABB `[min,max]`.
    pub fn glide_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
        let (c, z) = self.view_for_bounds(min, max);
        self.glide_to(c, z);
    }

    /// World coordinates -> screen pixels (origin top-left; matches the label projection).
    pub fn project(&self, world: [f32; 2]) -> [f32; 2] {
        let rel_x = (world[0] - self.center[0]) * self.zoom;
        let rel_y = (world[1] - self.center[1]) * self.zoom;
        [self.viewport[0] * 0.5 + rel_x, self.viewport[1] * 0.5 - rel_y]
    }

    /// Screen pixels (origin top-left) -> world coordinates. Exact inverse of
    /// `project`, including the y-flip: screen y grows downward, world y up.
    pub fn unproject(&self, screen: [f32; 2]) -> [f32; 2] {
        let rel_x = screen[0] - self.viewport[0] * 0.5;
        let rel_y = self.viewport[1] * 0.5 - screen[1];
        [self.center[0] + rel_x / self.zoom, self.center[1] + rel_y / self.zoom]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn project_maps_world_to_screen() {
        let mut cam = Camera::new(800.0, 600.0);
        cam.center = [0.0, 0.0];
        cam.zoom = 2.0;
        // world origin -> screen center
        assert_eq!(cam.project([0.0, 0.0]), [400.0, 300.0]);
        // +x world moves right; +y world moves UP (screen y down) => smaller screen y
        assert_eq!(cam.project([10.0, 0.0]), [420.0, 300.0]);
        assert_eq!(cam.project([0.0, 10.0]), [400.0, 280.0]);
    }

    #[test]
    fn snap_to_sets_immediately_and_not_gliding() {
        let mut cam = Camera::new(800.0, 600.0);
        cam.snap_to([5.0, 6.0], 42.0);
        assert_eq!(cam.center, [5.0, 6.0]);
        assert_eq!(cam.zoom, 42.0);
        assert!(!cam.gliding);
        cam.tick(16.0, 200.0); // no-op when not gliding
        assert_eq!(cam.center, [5.0, 6.0]);
    }

    #[test]
    fn glide_converges_to_target_and_stops() {
        let mut cam = Camera::new(800.0, 600.0);
        cam.snap_to([0.0, 0.0], 100.0);
        cam.glide_to([100.0, 0.0], 100.0);
        assert!(cam.gliding);
        let mut prev = 100.0_f32; // distance to target
        for _ in 0..600 {
            cam.tick(16.0, 200.0);
            let d = (cam.center[0] - 100.0).abs();
            assert!(d <= prev + 1e-3, "distance must not grow: {d} > {prev}");
            prev = d;
            if !cam.gliding { break; }
        }
        assert!(!cam.gliding, "glide should terminate");
        assert!((cam.center[0] - 100.0).abs() < 1.0);
    }

    #[test]
    fn glide_with_zero_duration_snaps() {
        let mut cam = Camera::new(800.0, 600.0);
        cam.snap_to([0.0, 0.0], 100.0);
        cam.glide_to([50.0, 50.0], 200.0);
        cam.tick(16.0, 0.0);
        assert_eq!(cam.center, [50.0, 50.0]);
        assert!(!cam.gliding);
    }

    #[test]
    fn unproject_inverts_project() {
        let mut cam = Camera::new(800.0, 600.0);
        cam.center = [12.0, -30.0];
        cam.zoom = 2.5;
        for p in [[0.0, 0.0], [12.0, -30.0], [100.0, 250.0], [-77.5, 3.25]] {
            let back = cam.unproject(cam.project(p));
            assert!((back[0] - p[0]).abs() < 1e-3, "x round-trip for {p:?} -> {back:?}");
            assert!((back[1] - p[1]).abs() < 1e-3, "y round-trip for {p:?} -> {back:?}");
        }
    }

    #[test]
    fn unproject_maps_screen_center_to_camera_center() {
        let mut cam = Camera::new(800.0, 600.0);
        cam.center = [5.0, 7.0];
        cam.zoom = 3.0;
        let w = cam.unproject([400.0, 300.0]);
        assert!((w[0] - 5.0).abs() < 1e-4);
        assert!((w[1] - 7.0).abs() < 1e-4);
    }

    #[test]
    fn unproject_respects_the_y_flip() {
        let mut cam = Camera::new(800.0, 600.0);
        cam.center = [0.0, 0.0];
        cam.zoom = 1.0;
        // Screen y grows downward; world y grows upward.
        let above = cam.unproject([400.0, 200.0]); // 100px ABOVE centre
        assert!(above[1] > 0.0, "screen-up must be world-positive, got {above:?}");
    }

    #[test]
    fn unproject_round_trips_at_several_zooms() {
        for zoom in [0.25_f32, 1.0, 4.0, 100.0] {
            let mut cam = Camera::new(1280.0, 720.0);
            cam.zoom = zoom;
            cam.center = [3.0, -4.0];
            let p = [42.0, -17.0];
            let back = cam.unproject(cam.project(p));
            assert!((back[0] - p[0]).abs() < 1e-2, "zoom {zoom}");
            assert!((back[1] - p[1]).abs() < 1e-2, "zoom {zoom}");
        }
    }
}