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::{
REFERENCE_VMIN, clamp01, ease_out_cubic, interpolate, normalised_speed, smoothstep,
};
pub const ECHO_STACK_PITCH: f32 = 1.05;
pub const HIDDEN: f32 = 0.55;
pub const MAIN: [f32; 2] = [0.0, 14.0];
pub const UNFOLD: [f32; 2] = [8.0, 26.0];
pub const FADE_IN: [f32; 2] = [8.0, 24.0];
pub const START_SPREAD: f32 = 0.6;
pub const MAX_BLUR: f32 = 12.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EchoDirection {
#[default]
Up,
Down,
}
impl EchoDirection {
pub fn sign(self) -> f32 {
match self {
Self::Up => -1.0,
Self::Down => 1.0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EchoStackConfig {
pub text: String,
pub font_size: f32,
pub font_weight: u16,
pub color: String,
pub echo_color: String,
pub echoes: usize,
pub echo_opacity: f32,
pub stroke_width: f32,
pub direction: EchoDirection,
pub cycle: f32,
pub speed: f32,
pub tail: usize,
pub simulate_blur: bool,
}
impl Default for EchoStackConfig {
fn default() -> Self {
Self {
text: "Bold.".to_owned(),
font_size: 170.0,
font_weight: 800,
color: "#e6edf3".to_owned(),
echo_color: String::new(),
echoes: 3,
echo_opacity: 0.35,
stroke_width: 1.5,
direction: EchoDirection::Up,
cycle: 60.0,
speed: 1.0,
tail: 45,
simulate_blur: true,
}
}
}
pub fn normalised_cycle(cycle: f32) -> f32 {
if cycle.is_finite() && cycle > 0.0 {
cycle
} else {
60.0
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MainChannels {
pub blur: f32,
pub opacity: f32,
pub scale: f32,
}
pub fn main_channels(frame: f32, config: &EchoStackConfig, unit: f32) -> MainChannels {
let local = frame * normalised_speed(config.speed);
let k = ease_out_cubic(clamp01(interpolate(local, MAIN, [0.0, 1.0])));
MainChannels {
blur: MAX_BLUR * (1.0 - k) * unit,
opacity: k,
scale: 1.04 - 0.04 * k,
}
}
pub fn echo_fade(distance: f32, echoes: usize) -> f32 {
let d = distance.abs();
if d < HIDDEN {
return 0.0;
}
if d < 1.0 {
return smoothstep((d - HIDDEN) / (1.0 - HIDDEN));
}
let reach = (echoes as f32 + 0.5).max(1.0);
(1.0 - (d - 1.0) / (reach - 1.0)).max(0.0)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EchoRow {
pub offset: f32,
pub position: f32,
pub opacity: f32,
}
pub fn echo_rows(frame: f32, config: &EchoStackConfig) -> Vec<EchoRow> {
let local = frame * normalised_speed(config.speed);
let echoes = config.echoes;
let cycle = normalised_cycle(config.cycle);
let phase = config.direction.sign() * ((local / cycle) % 1.0);
let spread = interpolate(
ease_out_cubic(clamp01(interpolate(local, UNFOLD, [0.0, 1.0]))),
[0.0, 1.0],
[START_SPREAD, 1.0],
);
let fade_in = ease_out_cubic(clamp01(interpolate(local, FADE_IN, [0.0, 1.0])));
let mut rows = Vec::new();
let reach = echoes as i64 + 2;
for k in -reach..=reach {
let offset = k as f32 + phase;
let opacity = config.echo_opacity * fade_in * echo_fade(offset, echoes);
if opacity <= 0.0 {
continue;
}
rows.push(EchoRow {
offset,
position: offset * spread,
opacity,
});
}
rows
}
#[derive(Debug, Clone, Default)]
pub struct EchoStack {
pub config: EchoStackConfig,
}
impl EchoStack {
pub fn new(config: EchoStackConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "echo-stack",
name: "Echo Stack",
summary: "A word resolves inside a drifting stack of outlined echoes.",
feature: "echo-stack",
}
}
}
impl Component for EchoStack {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
let end = UNFOLD[1].max(FADE_IN[1]).max(MAIN[1]) / normalised_speed(self.config.speed);
end.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 main = main_channels(ctx.frame(), config, unit);
let rows = echo_rows(ctx.frame(), config);
let pitch = ECHO_STACK_PITCH * size;
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let baseline = center_y + ctx.baseline_offset(&family, size, size);
let echo_fill = if config.echo_color.is_empty() {
config.color.clone()
} else {
config.echo_color.clone()
};
let stroke = config.stroke_width * unit;
let mut nodes: Vec<Svgr<'_>> = Vec::new();
for row in rows {
nodes.push(fframes::svgr!(
<text x={center_x} y={baseline + row.position * pitch} font-family={family.clone()}
font-size={size} font-weight={config.font_weight} fill="none"
stroke={echo_fill.clone()} stroke-width={stroke} opacity={row.opacity}
text-anchor="middle">{config.text.clone()}</text>
));
}
let main_size = size * main.scale;
let main_baseline = center_y + ctx.baseline_offset(&family, main_size, main_size);
if config.simulate_blur && main.blur > 0.05 {
let alpha = blur_ghost_alpha(main.blur, unit);
let spread = main.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,
main_baseline + spread * (copy as f32 / BLUR_GHOST_COPIES as f32) * sign,
family.clone(),
main_size,
config.font_weight,
0.0,
config.color.clone(),
main.opacity * alpha,
));
}
}
nodes.push(text_node_centered(
&config.text,
center_x,
main_baseline,
family,
main_size,
config.font_weight,
0.0,
config.color.clone(),
main.opacity,
));
let nodes = Svgr::from(nodes);
let clip_id = "echo-stack-clip".to_owned();
fframes::svgr!(
<g>
<defs>
<clipPath id={clip_id.clone()}>
<rect x="0" y="0" width={ctx.width()} height={ctx.height()} />
</clipPath>
</defs>
<g clip-path={format!("url(#{clip_id})")}>{nodes}</g>
</g>
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_main_word_resolves_over_its_window() {
let config = EchoStackConfig::default();
let start = main_channels(0.0, &config, 1.0);
assert!(start.opacity.abs() < 1e-6);
assert!((start.blur - MAX_BLUR).abs() < 1e-6);
assert!((start.scale - 1.04).abs() < 1e-6);
let end = main_channels(14.0, &config, 1.0);
assert!((end.opacity - 1.0).abs() < 1e-6);
assert!(end.blur.abs() < 1e-6);
assert!((end.scale - 1.0).abs() < 1e-6);
}
#[test]
fn nothing_is_drawn_inside_the_hidden_band() {
for distance in [-0.4_f32, -0.1, 0.0, 0.3, 0.54] {
assert!(echo_fade(distance, 3).abs() < 1e-6, "drew at {distance}");
}
assert!(echo_fade(1.0, 3) > 0.0);
}
#[test]
fn echoes_fade_out_beyond_their_reach() {
let near = echo_fade(1.0, 3);
let far = echo_fade(2.0, 3);
assert!(near > far, "{near} should exceed {far}");
assert!(echo_fade(4.0, 3).abs() < 1e-6);
}
#[test]
fn the_stack_starts_folded_and_opens() {
let config = EchoStackConfig::default();
assert!(
echo_rows(0.0, &config).is_empty(),
"no echo may be visible before the fade-in"
);
let mid = echo_rows(17.0, &config);
assert!(!mid.is_empty());
for row in &mid {
assert!(
row.position.abs() < row.offset.abs(),
"row {row:?} should not have fully unfolded yet"
);
}
let open = echo_rows(30.0, &config);
assert!(!open.is_empty());
for row in &open {
assert!(
(row.position - row.offset).abs() < 1e-5,
"row {row:?} did not unfold"
);
assert!(row.opacity > 0.0);
}
}
#[test]
fn the_phase_drifts_and_the_direction_flips_it() {
let up = EchoStackConfig::default();
let down = EchoStackConfig {
direction: EchoDirection::Down,
..EchoStackConfig::default()
};
let up_rows = echo_rows(15.0, &up);
let down_rows = echo_rows(15.0, &down);
assert!(!up_rows.is_empty() && !down_rows.is_empty());
let up_phase = up_rows[0].offset - up_rows[0].offset.round();
let down_phase = down_rows[0].offset - down_rows[0].offset.round();
assert!(
(up_phase + down_phase).abs() < 1e-5,
"{up_phase} vs {down_phase}"
);
}
#[test]
fn a_useless_cycle_falls_back() {
assert!((normalised_cycle(0.0) - 60.0).abs() < 1e-6);
assert!((normalised_cycle(f32::NAN) - 60.0).abs() < 1e-6);
assert!((normalised_cycle(12.0) - 12.0).abs() < 1e-6);
}
#[test]
fn duration_matches_the_documented_formula() {
let component = EchoStack::default();
assert_eq!(component.duration(), 71);
assert_eq!(component.meta().id, "echo-stack");
}
}