use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::RenderCtx;
use crate::motion::{Spring, clamp01, lerp};
use crate::ui::text;
pub const BOX_W: f32 = 400.0;
pub const BOX_H: f32 = 200.0;
pub const MAX_SHARE: f32 = 0.4;
pub const BACKGROUND: &str = "#f4f5f8";
pub const WHEEL_RADIUS: f32 = 100.0;
pub const PERSPECTIVE: f32 = 10000.0;
pub const DIM_OPACITY: f32 = 0.28;
pub const BRIGHTEN_FROM: f32 = 0.88;
pub const MASK_STOPS: [f32; 4] = [0.0, 0.3, 0.7, 1.0];
pub const INK: &str = "#182033";
pub const FONT_SIZE: f32 = 65.0;
pub const FONT_WEIGHT: u16 = 700;
pub const SPIN_SHARE: f32 = 0.75;
pub const REST_THRESHOLD: f32 = 0.0001;
pub const SPIN_SPRING: Spring = Spring {
damping: 200.0,
mass: 10.0,
stiffness: 200.0,
};
#[must_use]
pub fn natural_frames() -> f32 {
for frame in 0..100_000 {
if (SPIN_SPRING.value(frame as f32) - 1.0).abs() < REST_THRESHOLD {
return frame as f32;
}
}
100_000.0
}
#[must_use]
pub fn progress(frame: f32, duration: usize) -> f32 {
let span = (duration as f32 * SPIN_SHARE).max(1.0);
if frame >= span {
return 1.0;
}
SPIN_SPRING.value(frame.max(0.0) * natural_frames() / span)
}
#[must_use]
pub fn rotation(progress: f32) -> f32 {
lerp(1.0, 0.0, progress)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RowPose {
pub y: f32,
pub scale: f32,
pub squeeze: f32,
pub visible: bool,
}
#[must_use]
pub fn row_pose(index: usize, count: usize, rotation: f32) -> RowPose {
let wheel_index = index as f32 / count.max(1) as f32 + rotation;
let angle = wheel_index * std::f32::consts::TAU;
let facing = angle.cos();
let scale = PERSPECTIVE / (PERSPECTIVE - facing * WHEEL_RADIUS);
RowPose {
y: angle.sin() * WHEEL_RADIUS * scale,
scale,
squeeze: facing * facing,
visible: facing > 1e-4,
}
}
#[must_use]
pub fn row_opacity(index: usize, progress: f32) -> f32 {
if index == 0 {
let t = clamp01((progress - BRIGHTEN_FROM) / (1.0 - BRIGHTEN_FROM));
lerp(DIM_OPACITY, 1.0, t)
} else {
DIM_OPACITY
}
}
#[must_use]
pub fn scene_scale(canvas_width: f32, canvas_height: f32) -> f32 {
(MAX_SHARE * canvas_width / BOX_W).min(MAX_SHARE * canvas_height / BOX_H)
}
#[must_use]
pub fn parse_items(items: &str) -> Vec<String> {
items
.split('\n')
.map(|item| item.trim().to_owned())
.filter(|item| !item.is_empty())
.collect()
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpinningTextWheelConfig {
pub items: String,
pub color: String,
pub font_size: f32,
pub font_weight: u16,
pub font_family: Option<String>,
pub duration: usize,
}
impl Default for SpinningTextWheelConfig {
fn default() -> Self {
Self {
items: "Friday\nSaturday\nSunday\nMonday\nTuesday\nWednesday\nThursday".to_owned(),
color: INK.to_owned(),
font_size: FONT_SIZE,
font_weight: FONT_WEIGHT,
font_family: None,
duration: 150,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SpinningTextWheel {
pub config: SpinningTextWheelConfig,
}
impl SpinningTextWheel {
#[must_use]
pub fn new(config: SpinningTextWheelConfig) -> Self {
Self { config }
}
#[must_use]
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "spinning-text-wheel",
name: "Spinning Text Wheel",
summary: "A vertical text wheel spins a full turn and settles on its first item",
feature: "spinning-text-wheel",
}
}
}
impl Component for SpinningTextWheel {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
self.config.duration
}
fn background(&self) -> Option<&'static str> {
Some(BACKGROUND)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let family = config
.font_family
.clone()
.unwrap_or_else(|| crate::INTER_FAMILY.to_owned());
let items = parse_items(&config.items);
let frame = ctx.frame();
let scale = scene_scale(ctx.width(), ctx.height());
let fit = format!(
"translate({} {}) scale({scale})",
(ctx.width() - BOX_W * scale) / 2.0,
(ctx.height() - BOX_H * scale) / 2.0
);
let p = progress(frame, config.duration);
let turns = rotation(p);
let baseline = ctx.baseline_offset_at(
&family,
config.font_weight,
config.font_size,
config.font_size,
) - config.font_size / 2.0;
let mut rows: Vec<Svgr<'static>> = Vec::new();
for (index, item) in items.iter().enumerate() {
let pose = row_pose(index, items.len(), turns);
if !pose.visible {
continue;
}
let transform = format!(
"translate({} {}) scale({} {})",
BOX_W / 2.0,
BOX_H / 2.0 + pose.y,
pose.scale,
pose.scale * pose.squeeze
);
rows.push(fframes::svgr!(
<g transform={transform}>
{text::text_node(
ctx,
item,
0.0,
baseline,
family.clone(),
config.font_size,
config.font_weight,
0.0,
config.color.clone(),
row_opacity(index, p),
)}
</g>
));
}
fframes::svgr!(
<g transform={fit}>
<defs>
<linearGradient id="spinning-text-wheel-fade" gradientUnits="userSpaceOnUse"
x1={0.0} y1={0.0} x2={0.0} y2={BOX_H}>
<stop offset={MASK_STOPS[0]} stop-color="#000000" />
<stop offset={MASK_STOPS[1]} stop-color="#ffffff" />
<stop offset={MASK_STOPS[2]} stop-color="#ffffff" />
<stop offset={MASK_STOPS[3]} stop-color="#000000" />
</linearGradient>
<mask id="spinning-text-wheel-mask" maskUnits="userSpaceOnUse"
x={0.0} y={0.0} width={BOX_W} height={BOX_H}>
<rect x={0.0} y={0.0} width={BOX_W} height={BOX_H}
fill="url(#spinning-text-wheel-fade)" />
</mask>
</defs>
<g mask="url(#spinning-text-wheel-mask)">
{Svgr::from(rows)}
</g>
</g>
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_defaults_are_the_documented_config() {
let config = SpinningTextWheelConfig::default();
let items = parse_items(&config.items);
assert_eq!(items.len(), 7);
assert_eq!(items[0], "Friday");
assert_eq!(items[6], "Thursday");
assert_eq!(config.color, "#182033");
assert_eq!(config.font_size, 65.0);
assert_eq!(config.font_weight, 700);
assert_eq!(config.duration, 150);
}
#[test]
fn the_meta_names_the_component_and_its_feature() {
let component = SpinningTextWheel::default();
assert_eq!(component.meta().id, "spinning-text-wheel");
assert_eq!(
SpinningTextWheel::meta_static().feature,
"spinning-text-wheel"
);
assert_eq!(component.duration(), 150);
assert_eq!(component.background(), Some(BACKGROUND));
}
#[test]
fn items_are_trimmed_and_blanks_dropped() {
assert_eq!(parse_items(" a \n\n b\n "), ["a", "b"]);
}
#[test]
fn the_spin_starts_a_turn_out_and_comes_to_rest() {
assert!(progress(0.0, 150).abs() < 1e-6);
assert!((rotation(0.0) - 1.0).abs() < 1e-6);
assert!((progress(112.4, 150) - 1.0).abs() < 1e-3);
assert_eq!(progress(149.0, 150), 1.0);
assert!(rotation(1.0).abs() < 1e-6);
let mid = progress(30.0, 150);
assert!(mid > 0.0 && mid < 1.0);
assert!(progress(40.0, 150) >= mid);
}
#[test]
fn a_full_turn_looks_like_rest() {
for i in 0..7 {
let start = row_pose(i, 7, 1.0);
let rest = row_pose(i, 7, 0.0);
assert!((start.y - rest.y).abs() < 1e-2);
assert_eq!(start.visible, rest.visible);
}
}
#[test]
fn at_rest_the_first_row_is_centred_and_upright() {
let pose = row_pose(0, 7, 0.0);
assert!(pose.y.abs() < 1e-4);
assert!((pose.squeeze - 1.0).abs() < 1e-6);
assert!(pose.visible);
let next = row_pose(1, 7, 0.0);
assert!(next.y > 0.0 && next.y < WHEEL_RADIUS);
}
#[test]
fn rows_facing_away_are_hidden() {
assert!(!row_pose(3, 7, 0.0).visible);
assert!(!row_pose(4, 7, 0.0).visible);
}
#[test]
fn only_the_first_row_brightens() {
assert_eq!(row_opacity(0, 0.5), DIM_OPACITY);
assert_eq!(row_opacity(0, 0.88), DIM_OPACITY);
assert!((row_opacity(0, 1.0) - 1.0).abs() < 1e-6);
assert_eq!(row_opacity(2, 1.0), DIM_OPACITY);
}
#[test]
fn the_box_fits_landscape_and_portrait_canvases() {
let landscape = scene_scale(1920.0, 1080.0);
assert!(BOX_W * landscape <= 1920.0 && BOX_H * landscape <= 1080.0);
let portrait = scene_scale(1080.0, 1920.0);
assert!(BOX_W * portrait <= 1080.0);
}
}