# 弹簧曲线（Spring Timing Explorer）

- 类型 / 材质：`motion` / `spring-ios`（弹性）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：iOS UIKit / SwiftUI 的 spring 动画（UISpringTimingParameters）
- 依据标准：`/design#motion` ｜ 组件页：`/showcase/component/motion/spring-ios` ｜ 条目：`/r/motion--spring-ios.json`
- 必缺状态：（无必缺状态）
- 整页场景：尚未接入

## 规格

- 基准参数：response 0.5s、dampingFraction 0.825
- 无惯性的入场用 100% 阻尼，带惯性的滑动用 80%
- 积分步长与动画时长是两件事，别混

## 参数（CSS 变量）

`--color-accent` `--color-bg` `--color-surface` `--color-text` `--color-text-muted` `--color-text-subtle` `--glass-border` `--glass-recess` `--shadow-md` `--text-2xs`

## Props 契约

这支不接收 props——自包含演示组件。

## 提示词（可直接粘给智能体）

```
复现「弹簧曲线」— Spring Timing Explorer（动效 / Motion）
灵感来源：iOS UIKit / SwiftUI 的 spring 动画（UISpringTimingParameters）
规格：
- 基准参数：response 0.5s、dampingFraction 0.825
- 无惯性的入场用 100% 阻尼，带惯性的滑动用 80%
- 积分步长与动画时长是两件事，别混
参数（CSS 变量）：--color-accent --color-bg --color-surface --color-text --color-text-muted --color-text-subtle --glass-border --glass-recess --shadow-md --text-2xs
必缺状态：（无必缺状态）
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/motion/spring-ios · 标准 motion /design#motion · 调研笔记 /showcase/component/motion/spring-ios#notes
```

## 调研笔记

import { useEffect, useRef, useState } from 'react';

interface Preset {
  id: string;
  label: string;
  k: number;
  c: number;
}

const PRESETS: Preset[] = [
  { id: 'ios', label: 'iOS 默认', k: 220, c: 22 },
  { id: 'bouncy', label: '软糖', k: 300, c: 9 },
  { id: 'critical', label: '无摆动', k: 220, c: 30 },
  { id: 'soft', label: '慢回弹', k: 90, c: 20 },
];

const WINDOW_MS = 3000;   // 曲线视窗
const V_MIN = -0.2;       // 值域（留过冲空间）
const V_MAX = 1.2;

export function SpringPlayground() {
  const [k, setK] = useState(220);
  const [c, setC] = useState(22);
  const [, setTick] = useState(0);

  const sim = useRef({ x: 0.15, v: 0, target: 0.7 });
  const samples = useRef<{ t: number; x: number }[]>([]);
  const params = useRef({ k, c });
  params.current = { k, c };
  const raf = useRef(0);
  const sleeping = useRef(true);
  const reduced = useRef(false);

  const trackRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    reduced.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (reduced.current) {
      sim.current.x = sim.current.target;
      setTick((t) => t + 1);
      return;
    }
    wake();
    return () => cancelAnimationFrame(raf.current);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const wake = () => {
    if (!sleeping.current) return;
    sleeping.current = false;
    let last = performance.now();
    const step = (now: number) => {
      const dt = Math.min(0.032, (now - last) / 1000);
      last = now;
      const s = sim.current;
      const { k: kk, c: cc } = params.current;
      // 半隐式欧拉
      s.v += (-kk * (s.x - s.target) - cc * s.v) * dt;
      s.x += s.v * dt;
      samples.current.push({ t: now, x: s.x });
      const cutoff = now - WINDOW_MS;
      while (samples.current.length && samples.current[0].t < cutoff) {
        samples.current.shift();
      }
      setTick((t) => t + 1);
      // 静止判定：误差 + 速度双阈值
      if (Math.abs(s.x - s.target) < 0.0005 && Math.abs(s.v) < 0.0005) {
        s.x = s.target;
        s.v = 0;
        sleeping.current = true;
        setTick((t) => t + 1);
        return;
      }
      raf.current = requestAnimationFrame(step);
    };
    raf.current = requestAnimationFrame(step);
  };

  const setTarget = (t: number) => {
    sim.current.target = Math.max(0.02, Math.min(0.98, t));
    if (reduced.current) {
      sim.current.x = sim.current.target;
      setTick((tick) => tick + 1);
      return;
    }
    samples.current.push({ t: performance.now(), x: sim.current.x });
    wake();
  };

  const onTrackClick = (e: React.MouseEvent) => {
    const el = trackRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    setTarget((e.clientX - r.left) / r.width);
  };

  const s = sim.current;
  const zeta = c / (2 * Math.sqrt(k));

  // 曲线点映射
  const W = 240;
  const H = 80;
  const now = samples.current.length
    ? samples.current[samples.current.length - 1].t
    : 0;
  const toY = (v: number) => H - ((v - V_MIN) / (V_MAX - V_MIN)) * H;
  const points = samples.current
    .map((p) => {
      const px = ((p.t - (now - WINDOW_MS)) / WINDOW_MS) * W;
      return `${px.toFixed(1)},${toY(p.x).toFixed(1)}`;
    })
    .join(' ');

  // 速度驱动的挤压拉伸
  const stretch = Math.min(0.22, Math.abs(s.v) * 0.12);

  return (
    <div className="w-full max-w-xl space-y-4">
      {/* 轨道：点击设定目标，球用弹簧追过去 */}
      <div
        ref={trackRef}
        onClick={onTrackClick}
        className="
          relative h-24 rounded-2xl cursor-pointer overflow-hidden
          bg-[var(--glass-recess)] border border-[var(--glass-border)]
        "
      >
        {/* 目标环 */}
        <div
          className="absolute top-1/2 size-8 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-dashed border-[var(--color-text-subtle)]/60"
          style={{ left: `${s.target * 100}%` }}
        />
        {/* 球 */}
        <div
          className="absolute top-1/2 size-8 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--color-accent)] shadow-[var(--shadow-md)]"
          style={{
            left: `${s.x * 100}%`,
            transform: `translate(-50%, -50%) scale(${1 + stretch}, ${1 - stretch})`,
          }}
        />
        <span className="absolute bottom-2 left-3 text-[length:var(--text-2xs)] text-[var(--color-text-subtle)] pointer-events-none">
          点击轨道设定目标
        </span>
      </div>

      {/* 实时曲线：位置随时间 */}
      <svg
        viewBox={`0 0 ${W} ${H}`}
        className="w-full h-20 rounded-xl bg-[var(--color-surface)] border border-[var(--glass-border)]"
        preserveAspectRatio="none"
        aria-hidden="true"
      >
        <line x1="0" x2={W} y1={toY(0)} y2={toY(0)} stroke="var(--glass-border)" strokeWidth="1" />
        <line x1="0" x2={W} y1={toY(1)} y2={toY(1)} stroke="var(--glass-border)" strokeWidth="1" />
        <line
          x1="0" x2={W} y1={toY(s.target)} y2={toY(s.target)}
          stroke="var(--color-accent)" strokeWidth="1" strokeDasharray="3 3" opacity="0.5"
        />
        <polyline
          points={points}
          fill="none"
          stroke="var(--color-accent)"
          strokeWidth="1.5"
          strokeLinejoin="round"
        />
      </svg>

      {/* 参数 */}
      <div className="grid grid-cols-2 gap-4">
        <label className="block">
          <span className="flex justify-between text-xs text-[var(--color-text-muted)] mb-1.5">
            <span>刚度 k</span>
            <span className="font-mono">{k}</span>
          </span>
          <input
            type="range" min={40} max={600} step={10} value={k}
            onChange={(e) => setK(Number(e.target.value))}
            className="w-full accent-[var(--color-accent)]"
            aria-label="弹簧刚度"
          />
        </label>
        <label className="block">
          <span className="flex justify-between text-xs text-[var(--color-text-muted)] mb-1.5">
            <span>阻尼 c</span>
            <span className="font-mono">{c}</span>
          </span>
          <input
            type="range" min={2} max={60} step={1} value={c}
            onChange={(e) => setC(Number(e.target.value))}
            className="w-full accent-[var(--color-accent)]"
            aria-label="弹簧阻尼"
          />
        </label>
      </div>

      {/* 预设 + 阻尼比 */}
      <div className="flex items-center gap-2 flex-wrap">
        {PRESETS.map((p) => (
          <button
            key={p.id}
            type="button"
            onClick={() => { setK(p.k); setC(p.c); }}
            className={`
              press-feedback px-3 py-1.5 rounded-full text-xs border
              transition-colors cursor-pointer
              ${k === p.k && c === p.c
                ? 'bg-[var(--color-text)] text-[var(--color-bg)] border-transparent'
                : 'bg-[var(--color-surface)] text-[var(--color-text-muted)] border-[var(--glass-border)] hover:text-[var(--color-text)]'}
            `}
          >
            {p.label}
          </button>
        ))}
        <span className="ml-auto text-xs font-mono text-[var(--color-text-subtle)]">
          ζ = {zeta.toFixed(2)}
          <span className="opacity-60">
            {zeta < 0.98 ? ' · 欠阻尼' : zeta <= 1.02 ? ' · 临界' : ' · 过阻尼'}
          </span>
        </span>
      </div>
    </div>
  );
}
