use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, text_node_centered};
use crate::motion::{EASE_IN_OUT, clamp01, lerp};
pub const BACKGROUND: &str = "#0a0a0a";
pub const CARD_COLOR: &str = "#1c1c1c";
pub const LABEL_COLOR: &str = "#e6edf3";
pub const CURSOR_ARROW: &str = "M5 3 L19 12.2 L12 13.4 L8.6 20.4 Z";
pub const CURSOR_SIZE_VMIN: f32 = 5.0;
pub const CURSOR_VIEWBOX: f32 = 24.0;
pub const IMG_WIDTH_VMIN: f32 = 120.0;
pub const IMG_HEIGHT_RATIO: f32 = 0.4;
pub const ZOOM_RATIO: f32 = 0.2;
pub const LABEL_SIZE_VMIN: f32 = 4.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Corner {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl Corner {
pub fn point(self) -> (f32, f32) {
match self {
Self::TopLeft => (-0.5, -0.5),
Self::TopRight => (0.5, -0.5),
Self::BottomLeft => (-0.5, 0.5),
Self::BottomRight => (0.5, 0.5),
}
}
pub fn id(self) -> &'static str {
match self {
Self::TopLeft => "topLeft",
Self::TopRight => "topRight",
Self::BottomLeft => "bottomLeft",
Self::BottomRight => "bottomRight",
}
}
pub fn pulses(self) -> bool {
matches!(self, Self::TopLeft | Self::BottomLeft)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct CameraPose {
pub x: f32,
pub y: f32,
pub z: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CursorPose {
pub x: f32,
pub y: f32,
pub opacity: f32,
pub scale: f32,
}
impl Default for CursorPose {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
opacity: 0.0,
scale: 1.0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FlyoverStep {
pub id: String,
pub pose: CameraPose,
pub cursor: CursorPose,
pub pulse: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CursorFlyoverConfig {
pub corners: Vec<Corner>,
pub step_duration: usize,
pub transition_duration: usize,
pub cursor_transition_duration: usize,
pub perspective: f32,
pub label: String,
}
impl Default for CursorFlyoverConfig {
fn default() -> Self {
Self {
corners: vec![
Corner::TopLeft,
Corner::TopRight,
Corner::BottomLeft,
Corner::BottomRight,
],
step_duration: 45,
transition_duration: 45,
cursor_transition_duration: 35,
perspective: 1200.0,
label: "App".to_owned(),
}
}
}
pub fn flyover_steps(
corners: &[Corner],
img_width: f32,
img_height: f32,
zoom: f32,
) -> Vec<FlyoverStep> {
let mut steps = Vec::with_capacity(corners.len() + 2);
steps.push(FlyoverStep {
id: "overview".to_owned(),
pose: CameraPose {
x: 0.0,
y: 0.0,
z: img_width * 0.5,
},
cursor: CursorPose {
x: 0.0,
y: 0.0,
opacity: 0.0,
scale: 1.0,
},
pulse: false,
});
for corner in corners {
let (px, py) = corner.point();
steps.push(FlyoverStep {
id: corner.id().to_owned(),
pose: CameraPose {
x: px * img_width,
y: py * img_height,
z: zoom,
},
cursor: CursorPose {
x: px * img_width,
y: py * img_height,
opacity: 1.0,
scale: 1.0,
},
pulse: corner.pulses(),
});
}
steps.push(FlyoverStep {
id: "zoom-out".to_owned(),
pose: CameraPose {
x: 0.0,
y: 0.0,
z: img_width * 0.6,
},
cursor: CursorPose {
x: 0.0,
y: 0.0,
opacity: 0.0,
scale: 0.5,
},
pulse: false,
});
steps
}
pub fn get_cursor_flyover_duration(corner_count: usize, step_duration: usize) -> usize {
(corner_count + 2) * step_duration
}
pub fn active_step(frame: f32, step_count: usize, step_duration: usize) -> usize {
let mut active = 0;
for index in 0..step_count {
if frame >= (index * step_duration) as f32 {
active = index;
}
}
active
}
pub fn projection_scale(perspective: f32, z: f32) -> f32 {
let denominator = perspective + z;
if denominator.abs() < f32::EPSILON {
0.0
} else {
perspective / denominator
}
}
pub fn project_point(
x: f32,
y: f32,
camera: CameraPose,
perspective: f32,
center_x: f32,
center_y: f32,
) -> (f32, f32) {
let scale = projection_scale(perspective, camera.z);
(
center_x + (x - camera.x) * scale,
center_y + (y - camera.y) * scale,
)
}
pub fn camera_pose(
steps: &[FlyoverStep],
frame: f32,
step_duration: usize,
transition_duration: usize,
) -> CameraPose {
let Some(first) = steps.first() else {
return CameraPose::default();
};
let active = active_step(frame, steps.len(), step_duration);
if active == 0 {
return first.pose;
}
let Some(target) = steps.get(active) else {
return first.pose;
};
let progress =
clamp01((frame - (active * step_duration) as f32) / transition_duration.max(1) as f32);
let eased = EASE_IN_OUT.ease(progress);
let from = steps.get(active - 1).map_or(first.pose, |step| step.pose);
CameraPose {
x: lerp(from.x, target.pose.x, eased),
y: lerp(from.y, target.pose.y, eased),
z: lerp(from.z, target.pose.z, eased),
}
}
pub fn cursor_pose(
steps: &[FlyoverStep],
frame: f32,
step_duration: usize,
cursor_transition_duration: usize,
) -> CursorPose {
let Some(first) = steps.first() else {
return CursorPose::default();
};
let active = active_step(frame, steps.len(), step_duration);
let target = steps.get(active).map_or(first.cursor, |step| step.cursor);
let from = if active == 0 {
first.cursor
} else {
steps
.get(active - 1)
.map_or(first.cursor, |step| step.cursor)
};
let progress = clamp01(
(frame - (active * step_duration) as f32) / cursor_transition_duration.max(1) as f32,
);
let eased = EASE_IN_OUT.ease(progress);
let base_scale = lerp(from.scale, target.scale, eased);
let within = clamp01((frame - (active * step_duration) as f32) / step_duration.max(1) as f32);
let pulse = if steps.get(active).is_some_and(|step| step.pulse) {
1.0 + (std::f32::consts::PI * within).sin()
} else {
1.0
};
CursorPose {
x: lerp(from.x, target.x, eased),
y: lerp(from.y, target.y, eased),
opacity: lerp(from.opacity, target.opacity, eased),
scale: base_scale * pulse,
}
}
#[derive(Debug, Clone, Default)]
pub struct CursorFlyover {
pub config: CursorFlyoverConfig,
}
impl CursorFlyover {
pub fn new(config: CursorFlyoverConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "cursor-flyover",
name: "Cursor Flyover",
summary: "A cursor leads a camera that dives to each corner of a UI surface.",
feature: "cursor-flyover",
}
}
}
impl Component for CursorFlyover {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
get_cursor_flyover_duration(self.config.corners.len(), self.config.step_duration).max(1)
}
fn background(&self) -> Option<&'static str> {
Some(BACKGROUND)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let vmin = ctx.vmin();
let img_width = vmin * IMG_WIDTH_VMIN;
let img_height = img_width * IMG_HEIGHT_RATIO;
let zoom = img_width * ZOOM_RATIO;
let frame = ctx.frame();
let steps = flyover_steps(&self.config.corners, img_width, img_height, zoom);
let camera = camera_pose(
&steps,
frame,
self.config.step_duration,
self.config.transition_duration,
);
let cursor = cursor_pose(
&steps,
frame,
self.config.step_duration,
self.config.cursor_transition_duration,
);
let scale = projection_scale(self.config.perspective, camera.z);
if !scale.is_finite() || scale <= 0.0 {
return Svgr::empty();
}
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let family = ctx.sans();
let (card_x, card_y) = project_point(
0.0,
0.0,
camera,
self.config.perspective,
center_x,
center_y,
);
let card_width = img_width * scale;
let card_height = img_height * scale;
let label_size = vmin * LABEL_SIZE_VMIN * scale;
let card = fframes::svgr!(
<rect x={card_x - card_width / 2.0} y={card_y - card_height / 2.0}
width={card_width} height={card_height} rx={vmin * 1.5 * scale}
fill={CARD_COLOR} />
);
let label = text_node_centered(
&self.config.label,
card_x,
card_y + ctx.baseline_offset(&family, label_size, label_size),
family,
label_size,
ctx.fonts.weight_medium,
0.0,
LABEL_COLOR.to_owned(),
1.0,
);
let (cursor_x, cursor_y) = project_point(
cursor.x,
cursor.y,
camera,
self.config.perspective,
center_x,
center_y,
);
let arrow_box = vmin * CURSOR_SIZE_VMIN * scale * cursor.scale;
let arrow_scale = if arrow_box.abs() < 1e-4 {
0.0
} else {
arrow_box / CURSOR_VIEWBOX
};
let transform = format!(
"translate({cursor_x:.4} {cursor_y:.4}) scale({arrow_scale:.6}) \
translate({:.4} {:.4})",
-CURSOR_VIEWBOX / 2.0,
-CURSOR_VIEWBOX / 2.0
);
let cursor_node = fframes::svgr!(
<g transform={transform} opacity={cursor.opacity}>
<path d={CURSOR_ARROW} fill="#ffffff" stroke="#000000" stroke-width="2"
stroke-linejoin="round" />
</g>
);
let nodes = Svgr::from(vec![card, label, cursor_node]);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn steps() -> Vec<FlyoverStep> {
let vmin = 10.8;
let img_width = vmin * IMG_WIDTH_VMIN;
flyover_steps(
&CursorFlyoverConfig::default().corners,
img_width,
img_width * IMG_HEIGHT_RATIO,
img_width * ZOOM_RATIO,
)
}
#[test]
fn steps_are_the_source_sequence() {
let steps = steps();
assert_eq!(steps.len(), 6);
let ids: Vec<&str> = steps.iter().map(|step| step.id.as_str()).collect();
assert_eq!(
ids,
vec![
"overview",
"topLeft",
"topRight",
"bottomLeft",
"bottomRight",
"zoom-out"
]
);
assert!((steps[0].pose.z - 648.0).abs() < 1e-3);
assert!((steps[1].pose.x + 648.0).abs() < 1e-3);
assert!((steps[1].pose.y + 259.2).abs() < 1e-3);
assert!((steps[1].pose.z - 259.2).abs() < 1e-3);
assert!((steps[5].pose.z - 777.6).abs() < 1e-3);
assert!((steps[1].cursor.x + 648.0).abs() < 1e-3);
assert!((steps[5].cursor.opacity).abs() < 1e-6);
assert!((steps[5].cursor.scale - 0.5).abs() < 1e-6);
let pulses: Vec<bool> = steps.iter().map(|step| step.pulse).collect();
assert_eq!(pulses, vec![false, true, false, true, false, false]);
}
#[test]
fn duration_is_steps_times_step_duration() {
assert_eq!(get_cursor_flyover_duration(4, 45), 270);
assert_eq!(get_cursor_flyover_duration(0, 45), 90);
assert_eq!(get_cursor_flyover_duration(4, 0), 0);
let two = CursorFlyover::new(CursorFlyoverConfig {
corners: vec![Corner::TopLeft, Corner::BottomRight],
..CursorFlyoverConfig::default()
});
assert_eq!(two.duration(), 180);
let degenerate = CursorFlyover::new(CursorFlyoverConfig {
step_duration: 0,
..CursorFlyoverConfig::default()
});
assert_eq!(degenerate.duration(), 1);
assert_eq!(CursorFlyover::default().duration(), 270);
assert_eq!(CursorFlyover::default().meta().id, "cursor-flyover");
assert_eq!(CursorFlyover::default().background(), Some("#0a0a0a"));
}
#[test]
fn active_step_holds_the_last_one() {
let steps = steps();
assert_eq!(active_step(0.0, steps.len(), 45), 0);
assert_eq!(active_step(44.9, steps.len(), 45), 0);
assert_eq!(active_step(45.0, steps.len(), 45), 1);
assert_eq!(active_step(224.0, steps.len(), 45), 4);
assert_eq!(active_step(225.0, steps.len(), 45), 5);
assert_eq!(active_step(10_000.0, steps.len(), 45), 5);
assert_eq!(active_step(0.0, steps.len(), 0), 5);
}
#[test]
fn projection_scale_is_the_css_perspective_divide() {
assert!((projection_scale(1200.0, 0.0) - 1.0).abs() < 1e-6);
assert!((projection_scale(1200.0, 648.0) - 1200.0 / 1848.0).abs() < 1e-6);
assert!(projection_scale(1200.0, 648.0) < projection_scale(1200.0, 259.2));
assert!(projection_scale(1200.0, -600.0) > 1.0);
assert!(projection_scale(1200.0, -1200.0).abs() < 1e-6);
assert!((projection_scale(0.0, 500.0)).abs() < 1e-6);
}
#[test]
fn project_point_scales_about_the_centre() {
let camera = CameraPose {
x: 0.0,
y: 0.0,
z: 0.0,
};
assert_eq!(
project_point(10.0, -20.0, camera, 1200.0, 960.0, 540.0),
(970.0, 520.0)
);
let camera = CameraPose {
x: 648.0,
y: 259.2,
z: 259.2,
};
let scale = projection_scale(1200.0, 259.2);
let (x, y) = project_point(0.0, 0.0, camera, 1200.0, 960.0, 540.0);
assert!((x - (960.0 - 648.0 * scale)).abs() < 1e-3);
assert!((y - (540.0 - 259.2 * scale)).abs() < 1e-3);
let (x, y) = project_point(648.0, 259.2, camera, 1200.0, 960.0, 540.0);
assert!((x - 960.0).abs() < 1e-3);
assert!((y - 540.0).abs() < 1e-3);
}
#[test]
fn camera_holds_the_overview_then_eases_between_poses() {
let steps = steps();
for frame in [0.0, 1.0, 22.0, 44.9] {
assert_eq!(camera_pose(&steps, frame, 45, 45), steps[0].pose);
}
let middle = camera_pose(&steps, 67.5, 45, 45);
let eased = crate::motion::lerp(steps[0].pose.z, steps[1].pose.z, EASE_IN_OUT.ease(0.5));
assert!((middle.z - eased).abs() < 1e-3);
let linear = (steps[0].pose.z + steps[1].pose.z) / 2.0;
assert!(
(middle.z - linear).abs() > 1.0,
"the curve must not be linear: {} vs {}",
middle.z,
linear
);
assert!(close(camera_pose(&steps, 80.0, 45, 30), steps[1].pose));
assert_eq!(camera_pose(&steps, 90.0, 45, 45), steps[1].pose);
let snapped = camera_pose(&steps, 45.0, 45, 0);
assert!(snapped.x.is_finite() && snapped.z.is_finite());
assert_eq!(camera_pose(&[], 10.0, 45, 45), CameraPose::default());
}
fn close(a: CameraPose, b: CameraPose) -> bool {
(a.x - b.x).abs() < 1e-3 && (a.y - b.y).abs() < 1e-3 && (a.z - b.z).abs() < 1e-3
}
#[test]
fn cursor_pose_leads_on_its_own_shorter_clock_and_pulses() {
let steps = steps();
assert!(cursor_pose(&steps, 0.0, 45, 35).opacity.abs() < 1e-6);
assert!(close_cursor(
cursor_pose(&steps, 44.9, 45, 35),
steps[0].cursor
));
let arrived = cursor_pose(&steps, 80.0, 45, 35);
assert!((arrived.x - steps[1].cursor.x).abs() < 1e-3);
assert!((arrived.opacity - 1.0).abs() < 1e-6);
assert!(
(camera_pose(&steps, 80.0, 45, 45).x - steps[1].pose.x).abs() > 1.0,
"the camera should still be moving when the cursor has landed"
);
let peak = cursor_pose(&steps, 67.5, 45, 35);
assert!(
(peak.scale - 2.0).abs() < 1e-4,
"expected 2.0, got {}",
peak.scale
);
assert!((cursor_pose(&steps, 45.0, 45, 35).scale - 1.0).abs() < 1e-6);
assert!(cursor_pose(&steps, 89.9, 45, 35).scale < 1.01);
let right = cursor_pose(&steps, 112.5, 45, 35);
assert!((right.scale - 1.0).abs() < 1e-6, "got {}", right.scale);
let zoom_out = cursor_pose(&steps, 270.0, 45, 35);
assert!((zoom_out.scale - 0.5).abs() < 1e-6);
assert!(zoom_out.opacity.abs() < 1e-6);
for frame in 0..=270 {
let pose = cursor_pose(&steps, frame as f32, 45, 35);
assert!(
pose.scale > 0.0 && pose.scale <= 2.0001,
"scale {}",
pose.scale
);
assert!(
(0.0..=1.0).contains(&pose.opacity),
"opacity {}",
pose.opacity
);
}
assert!(cursor_pose(&steps, 45.0, 45, 0).x.is_finite());
assert_eq!(cursor_pose(&[], 0.0, 45, 35), CursorPose::default());
}
fn close_cursor(a: CursorPose, b: CursorPose) -> bool {
(a.x - b.x).abs() < 1e-3
&& (a.y - b.y).abs() < 1e-3
&& (a.opacity - b.opacity).abs() < 1e-3
&& (a.scale - b.scale).abs() < 1e-3
}
#[test]
fn every_frame_renders_without_panicking() {
let component = CursorFlyover::default();
let meters = crate::embedded_meters();
let duration = component.duration();
assert_eq!(duration, 270);
for frame in 0..duration {
let mut raw = fframes::Frame::new(frame, frame, 30);
let info = crate::ctx::ComponentFrame {
local: frame as f32,
global: frame,
fps: 30.0,
duration,
width: 1920.0,
height: 1080.0,
};
let mut ctx = RenderCtx::new(&mut raw, crate::core::Fonts::default(), &meters, info);
let svg = component.show(&mut ctx);
assert!(
!format!("{svg}").is_empty(),
"frame {frame} produced nothing"
);
}
let flat = CursorFlyover::new(CursorFlyoverConfig {
perspective: 0.0,
..CursorFlyoverConfig::default()
});
let mut raw = fframes::Frame::new(0, 0, 30);
let info = crate::ctx::ComponentFrame {
local: 0.0,
global: 0,
fps: 30.0,
duration: flat.duration(),
width: 1920.0,
height: 1080.0,
};
let mut ctx = RenderCtx::new(&mut raw, crate::core::Fonts::default(), &meters, info);
let _ = flat.show(&mut ctx);
}
}