use fframes::Svgr;
use crate::blur::{BLUR_GHOST_COPIES, BLUR_GHOST_SPREAD_RATIO, blur_ghost_alpha};
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, text_node_centered};
use crate::motion::{FPS, REFERENCE_VMIN, Spring, clamp01, map_range, normalised_speed};
pub fn spring_settle_frames(spring: Spring, tolerance: f32) -> usize {
for frame in 0..(FPS as usize * 10) {
if (spring.value(frame as f32) - 1.0).abs() <= tolerance {
return frame;
}
}
(FPS as usize * 10).saturating_sub(1)
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackingInConfig {
pub text: String,
pub start_tracking: f32,
pub start_blur: f32,
pub end_tracking: f32,
pub font_size: f32,
pub color: String,
pub font_weight: u16,
pub damping: f32,
pub stiffness: f32,
pub opacity_frames: usize,
pub speed: f32,
pub tail: usize,
pub simulate_blur: bool,
}
impl Default for TrackingInConfig {
fn default() -> Self {
Self {
text: "TRACKING IN".to_owned(),
start_tracking: 0.5,
start_blur: 12.0,
end_tracking: -0.03,
font_size: 96.0,
color: "#e6edf3".to_owned(),
font_weight: 700,
damping: 18.0,
stiffness: 90.0,
opacity_frames: 15,
speed: 1.0,
tail: 45,
simulate_blur: true,
}
}
}
impl TrackingInConfig {
pub fn spring(&self) -> Spring {
Spring {
damping: self.damping,
mass: 1.0,
stiffness: self.stiffness,
}
}
}
pub fn tracking_channels(frame: f32, config: &TrackingInConfig, unit: f32) -> (f32, f32, f32) {
let local = frame * normalised_speed(config.speed);
let spring = config.spring().value(local);
let tracking = map_range(
spring,
[0.0, 1.0],
[config.start_tracking, config.end_tracking],
);
let blur = map_range(spring, [0.0, 1.0], [config.start_blur * unit, 0.0]);
let opacity = clamp01(local / config.opacity_frames.max(1) as f32);
(tracking, blur, opacity)
}
#[derive(Debug, Clone, Default)]
pub struct TrackingIn {
pub config: TrackingInConfig,
}
impl TrackingIn {
pub fn new(config: TrackingInConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "tracking-in",
name: "Tracking In",
summary: "A line closes up its letter spacing as it sharpens into place.",
feature: "tracking-in",
}
}
}
impl Component for TrackingIn {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
let settle = spring_settle_frames(self.config.spring(), 1e-4) as f32;
(settle / normalised_speed(self.config.speed))
.ceil()
.max(0.0) as usize
+ self.config.tail
}
fn background(&self) -> Option<&'static str> {
Some("#0d1117")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let family = ctx.fonts.sans.clone();
let unit = ctx.vmin() / REFERENCE_VMIN;
let size = config.font_size * unit;
let (tracking, blur, opacity) = tracking_channels(ctx.frame(), config, unit);
let center_x = ctx.width() / 2.0;
let baseline = ctx.height() / 2.0 + ctx.baseline_offset(&family, size, size * 1.2);
let mut nodes: Vec<Svgr<'_>> = Vec::new();
if config.simulate_blur && blur > 0.05 {
let alpha = blur_ghost_alpha(blur, unit);
let spread = blur * BLUR_GHOST_SPREAD_RATIO;
for copy in 1..=BLUR_GHOST_COPIES {
let sign = if copy % 2 == 0 { -1.0 } else { 1.0 };
nodes.push(text_node_centered(
&config.text,
center_x,
baseline + spread * (copy as f32 / BLUR_GHOST_COPIES as f32) * sign,
family.clone(),
size,
config.font_weight,
tracking,
config.color.clone(),
opacity * alpha,
));
}
}
nodes.push(text_node_centered(
&config.text,
center_x,
baseline,
family,
size,
config.font_weight,
tracking,
config.color.clone(),
opacity,
));
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channels_start_open_and_soft_and_end_tight_and_sharp() {
let config = TrackingInConfig::default();
let (tracking, blur, opacity) = tracking_channels(0.0, &config, 1.0);
assert!((tracking - config.start_tracking).abs() < 1e-6);
assert!((blur - config.start_blur).abs() < 1e-6);
assert!(opacity.abs() < 1e-6);
let (tracking, blur, opacity) = tracking_channels(120.0, &config, 1.0);
assert!((tracking - config.end_tracking).abs() < 1e-3);
assert!(blur.abs() < 1e-2);
assert!((opacity - 1.0).abs() < 1e-6);
}
#[test]
fn opacity_ramps_over_its_own_shorter_clock() {
let config = TrackingInConfig::default();
let (tracking, _, opacity) = tracking_channels(15.0, &config, 1.0);
assert!(
(opacity - 1.0).abs() < 1e-6,
"opacity should be done at 15 frames"
);
assert!(
tracking > config.end_tracking,
"tracking should still be closing at frame 15: {tracking}"
);
}
#[test]
fn softness_follows_the_canvas_scale() {
let config = TrackingInConfig::default();
let (_, blur, _) = tracking_channels(0.0, &config, 1.5);
assert!((blur - config.start_blur * 1.5).abs() < 1e-5);
}
#[test]
fn spring_settles_within_the_ceiling() {
let config = TrackingInConfig::default();
let settle = spring_settle_frames(config.spring(), 1e-4);
assert!(
settle > 10 && settle < 300,
"unexpected settle frame {settle}"
);
assert!((config.spring().value(settle as f32) - 1.0).abs() <= 1e-4);
}
#[test]
fn duration_matches_the_documented_formula() {
let component = TrackingIn::default();
let settle = spring_settle_frames(component.config.spring(), 1e-4);
assert_eq!(settle, 28);
assert_eq!(component.duration(), 73);
assert_eq!(component.meta().id, "tracking-in");
}
}