use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, char_centers};
use crate::motion::{REFERENCE_VMIN, ease_out_quart_value, normalised_speed};
#[derive(Debug, Clone, PartialEq)]
pub struct PeriodDropConfig {
pub text: String,
pub font_size: f32,
pub font_weight: u16,
pub color: String,
pub dot_color: String,
pub dot_size: f32,
pub rise_frames: usize,
pub rise_stagger: usize,
pub drop_start: f32,
pub fall_frames: f32,
pub restitution: f32,
pub bounces: usize,
pub drop_height: f32,
pub stretch: f32,
pub squash: f32,
pub recover: f32,
pub speed: f32,
pub tail: usize,
}
impl Default for PeriodDropConfig {
fn default() -> Self {
Self {
text: "remocn".to_owned(),
font_size: 160.0,
font_weight: 800,
color: "#e6edf3".to_owned(),
dot_color: "#d97757".to_owned(),
dot_size: 0.2,
rise_frames: 12,
rise_stagger: 2,
drop_start: 20.0,
fall_frames: 12.0,
restitution: 0.35,
bounces: 2,
drop_height: 0.62,
stretch: 0.3,
squash: 0.22,
recover: 1.1,
speed: 1.0,
tail: 45,
}
}
}
pub fn rise_offset(frame: f32, index: usize, config: &PeriodDropConfig) -> f32 {
let local = frame * normalised_speed(config.speed);
let start = (config.rise_stagger * index) as f32;
let span = config.rise_frames.max(1) as f32;
let p = ease_out_quart_value(((local - start) / span).clamp(0.0, 1.0));
1.0 - p
}
pub fn impacts(config: &PeriodDropConfig) -> Vec<f32> {
let fall = config.fall_frames;
let mut out = vec![config.drop_start + fall];
for n in 1..=config.bounces {
let previous = out[n - 1];
out.push(previous + 2.0 * config.restitution.powi(n as i32) * fall);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DotState {
pub visible: bool,
pub y: f32,
pub scale_x: f32,
pub scale_y: f32,
}
pub fn dot_scale(deform: f32, config: &PeriodDropConfig) -> (f32, f32) {
let scale_y = if deform >= 0.0 {
1.0 + config.stretch * deform
} else {
1.0 + config.squash * deform.max(-1.0)
};
(1.0 / scale_y, scale_y)
}
pub fn dot_state(frame: f32, config: &PeriodDropConfig) -> DotState {
let t = frame * normalised_speed(config.speed);
let fall = config.fall_frames.max(f32::EPSILON);
if t < config.drop_start {
return DotState {
visible: false,
y: -1.0,
scale_x: 1.0,
scale_y: 1.0,
};
}
let g = 2.0 / (fall * fall);
let landing = g * fall;
let impacts = impacts(config);
let mut y = 0.0_f32;
let mut speed = 0.0_f32;
let mut deform;
if t < impacts[0] {
let u = t - config.drop_start;
y = -1.0 + 0.5 * g * u * u;
speed = g * u;
deform = speed / landing;
} else {
let mut n = impacts.len() - 1;
while n > 0 && t < impacts[n] {
n -= 1;
}
let tau = t - impacts[n];
let bounce = n + 1;
let launch = if bounce < impacts.len() {
config.restitution.powi(bounce as i32) * landing
} else {
0.0
};
let flight = if launch > 0.0 { 2.0 * launch / g } else { 0.0 };
if tau < flight {
y = -(launch * tau - 0.5 * g * tau * tau);
speed = (launch - g * tau).abs();
}
let impact = config.restitution.powi(n as i32);
let decay = (-tau / config.recover.max(f32::EPSILON)).exp();
deform = -impact * decay + (1.0 - decay) * (speed / landing);
}
if deform.abs() < 0.005 {
deform = 0.0;
}
let (scale_x, scale_y) = dot_scale(deform, config);
DotState {
visible: true,
y: y.min(0.0),
scale_x,
scale_y,
}
}
pub fn motion_frames(config: &PeriodDropConfig) -> usize {
let last_impact = impacts(config).last().copied().unwrap_or(0.0);
let rise = (config.rise_stagger * config.text.chars().count().saturating_sub(1)) as f32
+ config.rise_frames as f32;
let span = last_impact.max(rise) / normalised_speed(config.speed);
span.ceil().max(0.0) as usize
}
#[derive(Debug, Clone, Default)]
pub struct PeriodDrop {
pub config: PeriodDropConfig,
}
impl PeriodDrop {
pub fn new(config: PeriodDropConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "period-drop",
name: "Period Drop",
summary: "A word rises letter by letter, then a dot falls and bounces.",
feature: "period-drop",
}
}
}
impl Component for PeriodDrop {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
motion_frames(&self.config) + 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 frame = ctx.frame();
let (_, total) = char_centers(ctx, &family, &config.text, size);
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let ascent = ctx.ascent(&family, size);
let descent = size - ascent;
let baseline = center_y + ctx.baseline_offset(&family, size, size);
let clip_top = baseline - ascent - size * 0.2;
let clip_bottom = baseline + descent + size * 0.2;
let mut nodes: Vec<Svgr<'_>> = Vec::new();
let mut pen = -total / 2.0;
for (index, character) in config.text.chars().enumerate() {
let glyph = character.to_string();
let width = ctx.text_width(&family, size, &glyph);
let rise = rise_offset(frame, index, config);
let x = center_x + pen;
let dy = rise * size * 1.15;
let clip_id = format!("period-drop-{index}");
nodes.push(fframes::svgr!(
<g>
<defs>
<clipPath id={clip_id.clone()}>
<rect x={x - 1.0} y={clip_top} width={width + 2.0}
height={clip_bottom - clip_top} />
</clipPath>
</defs>
<g clip-path={format!("url(#{clip_id})")}>
<text x={x} y={baseline + dy} font-family={family.clone()}
font-size={size} font-weight={config.font_weight}
fill={config.color.clone()}
letter-spacing="0">{glyph}</text>
</g>
</g>
));
pen += width;
}
let dot = dot_state(frame, config);
if dot.visible {
let diameter = config.dot_size * size;
let drop = config.drop_height * ctx.height();
let rest_bottom = baseline;
let scaled_height = diameter * dot.scale_y;
let scaled_width = diameter * dot.scale_x;
let center_dot_x = center_x + total / 2.0 + size * 0.04 + scaled_width / 2.0;
let bottom = rest_bottom + dot.y * drop;
nodes.push(fframes::svgr!(
<ellipse cx={center_dot_x} cy={bottom - scaled_height / 2.0}
rx={scaled_width / 2.0} ry={scaled_height / 2.0}
fill={config.dot_color.clone()} />
));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letters_climb_one_stagger_apart() {
let config = PeriodDropConfig::default();
assert!((rise_offset(0.0, 0, &config) - 1.0).abs() < 1e-6);
assert!((rise_offset(0.0, 1, &config) - 1.0).abs() < 1e-6);
assert!(rise_offset(2.0, 0, &config) < 1.0);
assert!((rise_offset(2.0, 1, &config) - 1.0).abs() < 1e-6);
assert!(rise_offset(60.0, 5, &config).abs() < 1e-6);
}
#[test]
fn impact_frames_follow_the_restitution() {
let config = PeriodDropConfig::default();
let impacts = impacts(&config);
assert!((impacts[0] - 32.0).abs() < 1e-6);
assert!((impacts[1] - 40.4).abs() < 1e-6);
assert!((impacts[2] - 43.34).abs() < 1e-4);
}
#[test]
fn the_dot_is_hidden_until_release() {
let config = PeriodDropConfig::default();
let before = dot_state(19.0, &config);
assert!(!before.visible);
assert!((before.y + 1.0).abs() < 1e-6);
let after = dot_state(20.0, &config);
assert!(after.visible);
assert!((after.y + 1.0).abs() < 1e-6);
}
#[test]
fn the_dot_lands_at_the_rest_point_at_full_speed() {
let config = PeriodDropConfig::default();
let falling = dot_state(config.drop_start + config.fall_frames - 0.25, &config);
assert!(
falling.y.abs() < 0.06,
"expected the dot just above the rest point, got {}",
falling.y
);
assert!(
falling.scale_y > 1.2,
"full-speed fall should stretch the dot, got {}",
falling.scale_y
);
assert!(falling.scale_x < 1.0);
let contact = dot_state(config.drop_start + config.fall_frames, &config);
assert!(contact.y.abs() < 1e-6);
assert!(
contact.scale_y < 1.0,
"the impact should squash the dot, got {}",
contact.scale_y
);
}
#[test]
fn the_dot_squashes_on_impact_and_settles() {
let config = PeriodDropConfig::default();
let squash = dot_state(config.drop_start + config.fall_frames + 0.3, &config);
assert!(
squash.scale_y < 1.0,
"expected a squash, got {}",
squash.scale_y
);
assert!(squash.scale_x > 1.0, "the squash must preserve area");
assert!(
(squash.scale_x * squash.scale_y - 1.0).abs() < 1e-5,
"squash must preserve area"
);
let rest = dot_state(200.0, &config);
assert!(rest.y.abs() < 1e-6);
assert!((rest.scale_y - 1.0).abs() < 1e-6);
assert!((rest.scale_x - 1.0).abs() < 1e-6);
}
#[test]
fn the_dot_never_rises_above_its_release_point() {
let config = PeriodDropConfig::default();
for step in 0..600 {
let state = dot_state(step as f32 * 0.1, &config);
assert!(
state.y <= 1e-6,
"the dot went above the line at {}: {}",
step as f32 * 0.1,
state.y
);
}
}
#[test]
fn duration_matches_the_documented_formula() {
let component = PeriodDrop::default();
assert_eq!(motion_frames(&component.config), 44);
assert_eq!(component.duration(), 89);
assert_eq!(component.meta().id, "period-drop");
}
}