ZhuoQi Devzhuoqidev.comRSSllms.txt

← All entries~/design/status-icon-morph

MotionRecreatedRecreated from recent.design · 3-in-1 Icon Morph(Apple)

Three Status Icons Morph into One

Bars sink to dots, the battery becomes a line that wraps the Wi-Fi in a ring, and the dots slip into its gap. Timed frame by frame.

html · svg · jsMITAdded #icon#morph#path animation#status bar#frame-accurate

Live demoOpen in a new tab ↗

A 3.3-second recording on recent.design (an Apple design, video only): the signal, Wi-Fi and battery icons from a status bar merge into one Wi-Fi icon inside a ring. With an agent I pulled all 188 frames out with ffmpeg, measured every part on every frame with connected-component analysis, then rendered the recreation in the video’s own framing and diffed it against the recording pixel by pixel, until the average difference matched the anti-aliasing noise of a still frame (about 5%; the worst frame 9%).

What makes it work

Times count from the moment the first thing moves (the recording holds still for about a second first); the whole move takes 2.3 s.

  • The battery takes in its cap, then its body (0–0.53 s): the little bump on the right slides left into the battery first (out of sight by 0.07 s), then the left edge stays put while the right edge pulls in, as if being rolled up. The corner radius is always min(66, width/2), so halfway it turns into a pill on its own, and it ends as a vertical line 34 px thick and 180 px long.
  • The signal bars sink as if under a descending ceiling (0.07–0.70 s): the rightmost bar goes first, its top following a slow-fast-slow curve. Each bar to its left follows the same curve about 66 ms later and only starts once the ceiling reaches its own top, so shorter bars start later (0.07 / 0.20 / 0.33 / 0.47 s). All of them stop as 46 px dots; the bottoms never move.
  • The line is a snake crawling along a path (from 0.63 s): it drops straight down, takes a U-turn at the bottom right (0.77 s), spirals anticlockwise up and in, and at 0.97 s joins the final circle (radius 216.5) at the top. Along the way its length stays at 146 px; it starts slow, is fastest around 0.8 s, and eases a little as it joins the circle.
  • Once on the circle, head and tail part ways (1.13–2.03 s): the tail keeps slowing smoothly while the head suddenly speeds up (fastest around 1.27 s, about four times the tail), so the arc stretches from 37° to 240° in one go, 90% of it in 0.5 s, then settles. Counting from the start, the head goes round about one and a half times; the arc ends at 120° and 240° (clockwise from 12 o’clock), the gap facing down.
  • The Wi-Fi makes room (0.67–1.23 s): it only moves and scales, 33 px to the right and down to 82%, landing exactly on the centre of the final circle.
  • The four dots chase into the gap (1.07–2.23 s): starting with the rightmost, one every ~65 ms, they swing round a wide curve outside the circle (up to ~340 px from its centre) to land below it at 150°, 170°, 190° and 210° (20° apart). They overshoot about 25 px upward first (1.9 s), then sink back into place.
  • The circle is a little bigger while it forms: in the 0.3 s the line wraps round, its radius is 1–5 px larger than at the end and its centre slightly low; it settles by 1.4 s. Without this, overlaying the original leaves a thin rim.

How to use it

  1. The whole picture is one SVG in the recording’s own pixels, origin at the final circle’s centre, so the numbers in the tables are usable as they are; the viewBox scales it.
  2. The motion is not CSS keyframes but seven time tables (BODY, NUB, BARS, WIFI, LINE, DOTS, RING) plus one path (LOOP), one row per moment. draw(t) interpolates linearly between rows and draws the whole frame. To change the speed, scale the time; to play it backwards, run t down.
  3. The line is stored as arc length along the path: LINE only holds where its tail and head are, and strokeAlong() takes a point every 6 px and joins them, with stroke-linecap: round. The first part of the path is the measured polyline; past 20° it is the circle.
  4. The page has slow motion (¼×) and a timeline to step through it; ?t=1234 holds one moment, handy for screenshots next to the original.

Pitfalls

  • The capture is 30 fps but the file is 57 fps: each new picture shows for two frames or one. Timing by frame number puts the recreation half a frame ahead, so in an overlay the line and dots always lead. Find the frames where the picture actually changes and re-time them 33.3 ms apart.
  • Don’t fit it with ease: every part has its own speed curve (the battery fast then slow, the line speeding up then slowing, the ring’s head speeding up while its tail slows). No cubic Bézier gets there; tables measured frame by frame do.
  • Don’t trust small wobbles in a measured centre line: on the straight drop, the centre line measured from pixels wobbles by ±5 px, and drawing it as is puts a step in the line. A hand-drawn smooth curve here (easing 6 px right, then bending into the U-turn) matches the original better.
  • Don’t fit a circle to a short arc: an arc of a few tens of degrees fits to a small circle (the stroke’s thickness reads as curvature). Below half a turn measure the radius as the mean distance from a fixed centre; past half a turn, fit the centre too.
  • Hide the battery cap: after it slides in, the body keeps shrinking, and once the body is narrower the cap pokes out on the right again. Stop drawing it once it is in.
  • With prefers-reduced-motion it does not play: a click jumps straight to the merged (or the apart) state.
$For agents: index.md

Source MIT licensed. Take it.

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>三合一状态图标变形</title>
    <style>
      :root {
        --paper: #e4e4e4;
        --ink: #000;
        --ink-2: #5f5f63;
        --chip: rgba(0, 0, 0, 0.06);
        --chip-on: rgba(0, 0, 0, 0.12);
        --f-sans: system-ui, -apple-system, "Inter", "PingFang SC", "Microsoft YaHei", sans-serif;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #161618;
          --ink: #f4f4f4;
          --ink-2: #a0a0a6;
          --chip: rgba(255, 255, 255, 0.08);
          --chip-on: rgba(255, 255, 255, 0.16);
          color-scheme: dark;
        }
      }
      * { box-sizing: border-box; }
      body {
        margin: 0;
        min-height: 100vh;
        display: grid;
        grid-template-rows: 1fr auto;
        background: var(--paper);
        color: var(--ink);
        font: 14px/1.5 var(--f-sans);
      }

      /* ---------- the stage ----------
         One SVG in the reference video's own pixels, origin at the centre of the final circle. */
      .stage { display: grid; place-items: center; padding: 24px 16px 8px; cursor: pointer; }
      .stage svg { display: block; width: min(720px, 100%); height: auto; overflow: visible; }
      .stage svg * { fill: var(--ink); }
      .stage #line { fill: none; stroke: var(--ink); stroke-width: 34; stroke-linecap: round; stroke-linejoin: round; }
      .stage .arc { fill: none; stroke: var(--ink); stroke-linecap: round; }
      .stage #wedge { stroke: var(--ink); stroke-width: 30; stroke-linejoin: round; }

      /* ---------- controls ---------- */
      .bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px 10px; padding: 0 16px 28px; }
      .bar button, .bar label {
        font: inherit;
        color: var(--ink);
        background: var(--chip);
        border: 0;
        border-radius: 999px;
        padding: 7px 14px;
        cursor: pointer;
      }
      .bar button:hover, .bar label:hover { background: var(--chip-on); }
      .bar button:focus-visible, .bar input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
      .bar label { display: inline-flex; align-items: center; gap: 6px; user-select: none; }
      .bar label input { margin: 0; accent-color: var(--ink); }
      .scrub { display: flex; align-items: center; gap: 10px; width: min(420px, 100%); color: var(--ink-2); font-variant-numeric: tabular-nums; }
      .scrub input { flex: 1; min-width: 0; accent-color: var(--ink); }
      .scrub output { min-width: 9.5em; font-size: 12px; text-align: right; }
    </style>
  </head>
  <body>
    <div class="stage" id="stage" title="点一下:合成 / 拆开">
      <svg viewBox="-640 -330 1300 760" role="img" aria-labelledby="cap">
        <title id="cap">信号、Wi-Fi、电池三个图标合成一个带圈的 Wi-Fi 图标</title>
        <!-- Wi-Fi: two stroked arcs and a rounded wedge; it only slides and shrinks. -->
        <g id="wifi">
          <path class="arc" stroke-width="35" d="M -143.4 -31 A 158 158 0 0 1 76 -31" />
          <path class="arc" stroke-width="36" d="M -96 13.5 A 100 100 0 0 1 28.8 13.5" />
          <path id="wedge" d="M -55.3 60.4 L -33.7 82 L -12.1 60.4 A 47.9 47.9 0 0 0 -55.3 60.4 Z" />
        </g>
        <!-- Signal: four pills that sink into dots. -->
        <rect class="sig" width="46.4" rx="23.2" />
        <rect class="sig" width="46.4" rx="23.2" />
        <rect class="sig" width="46.4" rx="23.2" />
        <rect class="sig" width="46.4" rx="23.2" />
        <!-- Battery: the nub slides in first, then the body narrows into the line. -->
        <path id="nub" />
        <path id="body" />
        <path id="line" />
      </svg>
    </div>
    <div class="bar">
      <button type="button" id="play">拆开</button>
      <label><input type="checkbox" id="slow" /> 慢放 ¼×</label>
      <div class="scrub">
        <input type="range" id="scrub" min="0" step="1" aria-label="时间轴" />
        <output id="time" for="scrub"></output>
      </div>
    </div>
    <script>
      // Every track below was measured from the reference recording, frame by frame (57.14 fps file of a
      // 30 fps capture, so a new position every ~33 ms): t is ms since the first thing moves, coordinates are
      // the video's pixels with the origin at the centre of the final circle. Values in between are linear.
      //   BODY [t, left, right, top, bottom]      battery body (a rounded rectangle, corner = min(66, w/2))
      //   NUB  [t, right]                         the battery's little cap, hidden once inside the body
      //   BARS [t, top1..top4]                    signal bars (bottom fixed at 100.2); each sinks ~66 ms after
      //                                           the one to its right, never below a 46.4 px circle
      //   WIFI [t, cx, cy, scale]                 the Wi-Fi's centre and size (it only translates and scales)
      //   LINE [t, tail, head]                    where the battery's line is, as arc length along PATH
      //   DOTS [t, x1, y1 … x4, y4]               the four dots once they leave the bars
      //   RING [t, r, dx, dy]                     the circle is a few px wider (and its centre a little low) while
      //                                           the line wraps round
      //   LOOP                                    the measured path: the drop, the U-turn, the spiral in;
      //                                           after it the path is the final circle (r 216.5), anticlockwise
    const BODY = [[-33,204.1,557.1,-91.6,92.4],[0,204.1,553.5,-91.6,92.4],[33,204.1,541.8,-91.6,92.4],[67,204.1,522.5,-91.6,92.4],[100,203.9,494.5,-91.6,92],[133,203.7,459.8,-91.6,91.9],[167,203.7,422.7,-91.5,91.6],[200,203.6,386.3,-91.5,91.5],[233,203.5,352.7,-91.4,91.4],[267,203.5,323.7,-91.1,91.3],[300,203.3,299.6,-90.9,91.2],[333,203,279.7,-91.2,91.3],[367,202.7,264,-91,91],[400,202.4,252,-90.8,91.2],[433,202.3,243.3,-90.6,91.2],[467,201.6,237.3,-90.4,91.2],[500,201.4,234.2,-89.8,91.1],[533,201.3,233.2,-89.6,91.2],[567,200.6,233.2,-89.4,91.1],[600,200.3,233.2,-89.1,91]];
    const NUB = [[-33,589.8],[0,582.7],[33,559.7],[67,494]];
    const BARS = [[67,13.9,-15.2,-48.8,-80.7],[100,13.9,-15.2,-48.8,-73.6],[133,13.9,-15.2,-48.8,-62.3],[167,13.9,-15.2,-48.8,-46.6],[200,13.9,-15.2,-46.6,-28.9],[233,13.9,-15.2,-39.8,-11.2],[267,13.9,-15.2,-27.6,4.8],[300,13.9,-15.2,-12.1,18.6],[333,13.9,-13,4.1,29.8],[367,13.9,-5.3,18.3,38.5],[400,13.9,7.7,29.7,45.3],[433,13.9,21.7,38.5,49.7],[467,15.9,33,45,52.5],[500,22.9,41.5,49.5,53.7],[533,33.4,47.1,52.2,53.7],[567,41.6,50.6,53.6,53.7],[600,47.3,52.8,53.8,53.7],[633,50.5,53.7,53.8,53.7],[667,52.5,53.8,53.8,53.7],[700,53.7,53.8,53.8,53.7],[767,54.1,53.8,53.8,53.7]];
    const WIFI = [[567,-33.7,-19.5,1],[633,-33.7,-19.5,1],[667,-33,-19.5,0.9992],[700,-30.2,-19.2,0.9933],[733,-26.8,-18.6,0.9794],[767,-23.5,-17.7,0.9566],[800,-20.2,-16.7,0.9297],[833,-17.2,-15.7,0.9054],[867,-14.5,-15,0.8851],[900,-11.9,-14.4,0.8691],[933,-9.6,-13.8,0.857],[967,-7.6,-13.4,0.847],[1000,-5.9,-13.1,0.8392],[1033,-4.4,-12.8,0.833],[1067,-3.2,-12.6,0.8284],[1100,-2.2,-12.5,0.8249],[1133,-1.4,-12.4,0.8223],[1167,-0.9,-12.3,0.8205],[1200,-0.5,-12.2,0.8197],[1300,-0.5,-12.2,0.8194]];
    const LINE = [[600,5.9,151.9],[633,23.6,167.8],[667,47.9,192],[700,113.5,257.9],[733,210,348.9],[767,330.2,466.5],[800,443.4,587.8],[833,562.4,706.7],[867,677,820.6],[900,784.7,928.6],[933,885.9,1030.2],[967,980.6,1124.4],[1000,1068.9,1211.3],[1033,1151,1292.2],[1067,1227,1368.8],[1100,1298,1440.2],[1133,1364.9,1507.8],[1167,1428.8,1584.1],[1200,1487.4,1686.9],[1233,1539.5,1837.2],[1267,1589,2027.3],[1300,1635.9,2193.2],[1333,1678.2,2321.3],[1367,1719,2424.1],[1400,1756.4,2508.7],[1433,1791.6,2580.5],[1467,1823.7,2642.1],[1500,1853.9,2695],[1533,1879.6,2739.2],[1567,1901.9,2777],[1600,1920.4,2807.2],[1633,1936.3,2832.2],[1667,1949.5,2852.6],[1700,1960.8,2868.1],[1733,1970.7,2880.5],[1767,1977.5,2889.6],[1800,1983.5,2895.6],[1833,1988.8,2900.9],[1867,1993,2904.7],[1900,1995.6,2907.7],[1933,1997.5,2910],[1967,1999.4,2911.5],[2067,2001.6,2913.8],[2067,2001.6,2914.5],[2300,2002,2914.5]];
    const DOTS = [[933,-524.1,77,-447.8,77,-371.6,77,-295.3,77],[1033,-524.1,77,-447.8,77,-371.6,77,-295.3,77],[1067,-524.1,77,-447.8,77,-371.6,77,-295.3,79.1],[1100,-524.1,77,-447.8,77,-371.6,77,-293.3,115],[1133,-523.6,77.2,-447.3,77.2,-371,79.7,-287.1,150],[1167,-522.7,77.4,-446.5,77.4,-368.3,118.3,-276.6,183.6],[1200,-521.9,77.7,-445.7,80.2,-361.2,155.8,-261.6,215.1],[1233,-520.9,78.1,-442.5,120.4,-349.5,191.8,-241.7,243.6],[1267,-519.8,78.6,-435.4,159.4,-332.7,225.4,-217.6,268.3],[1300,-517.8,103.3,-423.6,197,-311.4,256,-190,288.4],[1333,-511.5,146.5,-406.5,232.3,-285.7,282.7,-159.6,303.4],[1367,-499.7,187.5,-384.4,264,-256.2,304.6,-127.8,313.4],[1400,-481.6,226.2,-357.3,291.1,-224.2,321.3,-95.3,318.8],[1433,-456.8,260.2,-326,312.6,-190.3,333.2,-63,319.2],[1467,-425.7,288.2,-291.7,327.6,-156,339.8,-31.8,315.2],[1500,-389.8,308.4,-256,336.5,-121.8,341.2,-2.3,307.2],[1533,-351,320.1,-220,338.6,-88.6,337.5,24.4,295.2],[1567,-311.6,324.2,-185.3,335.1,-57.7,329,48.1,280.3],[1600,-273.2,321.1,-152.5,326.4,-29.9,315.4,67.8,262.7],[1633,-237,311.9,-123.3,313,-6.6,298,83.1,243.5],[1667,-204.1,297.4,-97.7,296,11.1,277.9,94,224.6],[1700,-175.7,279,-76.8,276.7,23.2,256.8,101.3,207.3],[1733,-152.4,257.6,-60.8,256.6,30.2,237.3,105.7,194.1],[1767,-134.5,235.3,-49.4,237.6,33.9,222,108.1,186.6],[1800,-122.2,214,-42.1,222.6,36,212.9,110.1,180.8],[1833,-115.7,193,-39,209.5,36.6,203.1,110.7,172],[1867,-113.1,183.1,-37.6,203.4,36.7,198,110.5,167.5],[1900,-111.7,177.2,-36.4,198.8,36.7,194,110.4,164.7],[1933,-110.5,172.7,-35.7,195.1,36.5,191.1,110.4,165.1],[1967,-109.7,168.9,-35.2,192.6,36.6,191.7,110.5,168.3],[2000,-109.1,166.4,-35.2,193.2,36.6,194.9,110.5,171.9],[2033,-109.3,167,-35.8,196.4,36.7,198.6,110.5,175.5],[2067,-109.8,170.2,-36.3,199.9,36.7,202.4,110.4,179.1],[2100,-110.4,173.8,-37.1,203.5,36.7,206.1,110.4,182.7],[2133,-111.2,177.1,-37.7,207,36.7,209.3,110.4,185.3],[2167,-111.9,180.3,-38.3,209.8,36.7,212.1,110.5,187.6],[2200,-112.3,183.1,-38.9,212.4,36.7,214.4,110.4,189.3],[2233,-113,185.7,-39.5,214.6,36.7,216.2,110.4,190.8],[2267,-113.5,187.8,-39.9,216.4,36.6,217.4,110.3,191.2],[2300,-113.8,189.4,-40,217.3,36.7,218,110.3,191.2]];
    const RING = [[967,217.2,0,0],[1000,218.1,0,0],[1033,219.9,0,0],[1067,219.6,0,0],[1100,218.7,0,0],[1133,219.6,0,0],[1167,221.7,0,0],[1200,222,0,0],[1233,219.7,0,0],[1267,218.2,0,0],[1300,215.5,0.3,4.8],[1333,216.4,0.8,2.5],[1367,216.3,1.4,1.7],[1400,216.3,1.4,1.1],[1433,216.7,0.8,0.8],[1467,216.9,0.5,0.7],[1567,216.7,0.4,0.4],[1600,216.6,0.5,0.3],[1667,216.6,0.3,0.2]];
    const LOOP = [[216.8,-78.7],[216.8,-70.7],[216.8,-62.7],[216.8,-54.7],[216.8,-46.7],[216.8,-38.7],[216.8,-30.7],[216.8,-22.7],[216.8,-14.7],[216.8,-6.7],[216.8,1.3],[216.8,9.3],[216.8,17.3],[217,25.3],[217.1,33.3],[217.3,41.3],[217.6,49.3],[217.8,57.3],[218.1,65.3],[218.5,73.3],[218.8,81.3],[219.1,89.3],[219.5,97.3],[219.9,105.3],[220.2,113.3],[220.6,121.3],[220.9,129.3],[221.2,137.3],[221.5,145.3],[221.8,153.3],[222.1,161.3],[222.3,169.3],[222.5,177.3],[222.9,191.3],[223.2,195.3],[223.8,201.3],[224.7,209.3],[226,217.3],[227.6,225.3],[229.5,233.3],[232.2,241.7],[235.4,248.9],[239.2,254.8],[243.8,258.5],[249.2,261],[255.5,262.8],[262.6,264.1],[270.2,264.6],[278,264.2],[285.7,262.9],[293.2,260.7],[300.4,257.6],[307.2,253.6],[313.6,249],[319.5,243.9],[325,238.3],[330,232.5],[334.5,226.3],[338.6,219.8],[342.3,213],[345.7,206],[348.8,198.8],[351.6,191.4],[353.9,183.9],[355.9,176.1],[357.5,168.3],[358.9,160.5],[360,152.6],[360.9,144.6],[361.7,136.7],[362.3,128.8],[362.8,121],[362.9,113.2],[362.7,105.4],[362.3,97.6],[361.8,89.9],[361.1,82.1],[360,74.4],[358.5,66.7],[356.6,59.1],[354.3,51.4],[351.8,43.8],[349.2,36.2],[346.6,28.7],[343.9,21.3],[341.1,13.9],[338.1,6.7],[334.9,-0.5],[331.3,-7.5],[327.6,-14.5],[323.6,-21.4],[319.6,-28.3],[315.3,-35.1],[310.9,-41.7],[306.2,-48.1],[301.4,-54.5],[296.4,-60.7],[291.2,-66.8],[286,-72.9],[280.6,-78.7],[275.1,-84.4],[269.4,-89.7],[263.5,-94.9],[257.4,-99.8],[251.1,-104.8],[244.8,-109.6],[238.5,-114.4],[232,-119.2],[225.5,-123.8],[219,-128.4],[212.4,-133],[205.9,-137.5],[199.3,-142],[192.7,-146.4],[185.9,-150.5],[179.2,-154.5],[172.4,-158.5],[165.7,-162.4],[158.9,-166.3],[152.2,-170.2],[145.3,-174],[138.3,-177.7],[131.2,-181.2],[124.2,-184.5],[117.3,-187.7],[110.3,-190.7],[103.2,-193.6],[96.1,-196.3],[90.4,-198.3],[86.7,-199.7],[74,-203.4]];
      const R = 216.5;
      const JOIN = 20; // degrees clockwise from 12 o'clock where LOOP meets the circle
      const END = 2300;
      const SIG_X = [-524.1, -447.8, -371.6, -295.3];
      const SIG_BOTTOM = 100.2;
      const NUB_W = 21.1;
      const WIFI_0 = [-33.7, -19.5]; // the Wi-Fi's centre before it moves

      // Linear interpolation in a [t, ...values] track, held at both ends.
      const at = (track, t) => {
        if (t <= track[0][0]) return track[0].slice(1);
        for (let i = 1; i < track.length; i++) {
          const b = track[i];
          if (t <= b[0]) {
            const a = track[i - 1], k = (t - a[0]) / (b[0] - a[0]);
            return a.slice(1).map((v, j) => v + (b[j + 1] - v) * k);
          }
        }
        return track[track.length - 1].slice(1);
      };

      // Arc length along LOOP, then onward around the circle.
      const LEN = [0];
      for (let i = 1; i < LOOP.length; i++) LEN.push(LEN[i - 1] + Math.hypot(LOOP[i][0] - LOOP[i - 1][0], LOOP[i][1] - LOOP[i - 1][1]));
      const LOOP_LEN = LEN[LEN.length - 1];
      const point = (s, ring = [R, 0, 0]) => {
        if (s >= LOOP_LEN) {
          const a = ((JOIN * Math.PI) / 180) - (s - LOOP_LEN) / R;
          // the wider ring eases in over the first 30° past the join, so the path stays continuous
          const k = Math.min(1, (s - LOOP_LEN) / ((30 * Math.PI * R) / 180));
          const r = R + (ring[0] - R) * k;
          return [ring[1] * k + r * Math.sin(a), ring[2] * k - r * Math.cos(a)];
        }
        let i = 1;
        while (LEN[i] < s) i++;
        const k = (s - LEN[i - 1]) / (LEN[i] - LEN[i - 1]);
        return [LOOP[i - 1][0] + (LOOP[i][0] - LOOP[i - 1][0]) * k, LOOP[i - 1][1] + (LOOP[i][1] - LOOP[i - 1][1]) * k];
      };
      const f = (v) => Math.round(v * 10) / 10;
      const strokeAlong = (s0, s1, ring) => {
        let d = "";
        for (let s = s0; ; s = Math.min(s + 6, s1)) {
          const [x, y] = point(s, ring);
          d += (d ? " L " : "M ") + f(x) + " " + f(y);
          if (s >= s1) return d;
        }
      };
      const roundRect = (l, r, t, b) => {
        const k = Math.min(66, (r - l) / 2, (b - t) / 2);
        return `M ${f(l + k)} ${f(t)} H ${f(r - k)} A ${f(k)} ${f(k)} 0 0 1 ${f(r)} ${f(t + k)} V ${f(b - k)} A ${f(k)} ${f(k)} 0 0 1 ${f(r - k)} ${f(b)} H ${f(l + k)} A ${f(k)} ${f(k)} 0 0 1 ${f(l)} ${f(b - k)} V ${f(t + k)} A ${f(k)} ${f(k)} 0 0 1 ${f(l + k)} ${f(t)} Z`;
      };

      const $ = (id) => document.getElementById(id);
      const wifi = $("wifi"), sig = [...document.querySelectorAll(".sig")], nub = $("nub"), body = $("body"), line = $("line");
      const LINE_FROM = LINE[0][0], DOTS_FROM = DOTS[0][0], NUB_TO = NUB[NUB.length - 1][0];

      // The whole picture at time t (ms).
      const draw = (t) => {
        const [cx, cy, sc] = at(WIFI, t);
        wifi.setAttribute("transform", `translate(${f(cx)} ${f(cy)}) scale(${sc.toFixed(4)}) translate(${-WIFI_0[0]} ${-WIFI_0[1]})`);

        if (t < DOTS_FROM) {
          const tops = at(BARS, t);
          sig.forEach((el, i) => {
            el.setAttribute("x", f(SIG_X[i] - 23.2));
            el.setAttribute("y", f(tops[i]));
            el.setAttribute("height", f(SIG_BOTTOM - tops[i]));
          });
        } else {
          const p = at(DOTS, t);
          sig.forEach((el, i) => {
            el.setAttribute("x", f(p[i * 2] - 23.2));
            el.setAttribute("y", f(p[i * 2 + 1] - 23.2));
            el.setAttribute("height", 46.4);
          });
        }

        if (t < LINE_FROM) {
          const [l, r, top, bottom] = at(BODY, t);
          body.setAttribute("d", roundRect(l, r, top, bottom));
          line.setAttribute("d", "");
          const [nr] = at(NUB, t);
          nub.setAttribute("d", t < NUB_TO ? `M ${f(nr - NUB_W)} -27.5 A ${NUB_W} 26.85 0 0 1 ${f(nr - NUB_W)} 26.2 Z` : "");
        } else {
          body.setAttribute("d", "");
          nub.setAttribute("d", "");
          const [tail, head] = at(LINE, t);
          line.setAttribute("d", strokeAlong(tail, head, at(RING, t)));
        }
      };

      // ---------- playback ----------
      const play = $("play"), slow = $("slow"), scrub = $("scrub"), time = $("time");
      const still = matchMedia("(prefers-reduced-motion: reduce)");
      scrub.max = END;
      let t = 0, dir = 1, raf = 0, last = 0;
      const show = () => {
        draw(t);
        scrub.value = Math.round(t);
        time.textContent = `${(t / 1000).toFixed(2)} s(原片 ${((t + 958) / 1000).toFixed(2)} s)`;
        play.textContent = (raf ? dir > 0 : t >= END / 2) ? "拆开" : "合成";
      };
      const tick = (now) => {
        // a frame's timestamp can be a little before the moment run() was called: never step backwards
        const dt = Math.max(0, now - last) * (slow.checked ? 0.25 : 1);
        last = Math.max(last, now);
        t = Math.max(0, Math.min(END, t + dir * dt));
        raf = (dir > 0 ? t < END : t > 0) ? requestAnimationFrame(tick) : 0;
        show();
      };
      const run = (d) => {
        dir = d;
        cancelAnimationFrame(raf);
        if (still.matches) {
          t = d > 0 ? END : 0;
          show();
          return;
        }
        last = performance.now();
        raf = requestAnimationFrame(tick);
      };
      // Forward once it's whole, back once it's apart; a click mid-way turns it around.
      const toggle = () => run(raf ? -dir : t >= END / 2 ? -1 : 1);
      play.addEventListener("click", toggle);
      $("stage").addEventListener("click", toggle);
      scrub.addEventListener("input", () => {
        cancelAnimationFrame(raf);
        raf = 0;
        t = +scrub.value;
        dir = t >= END / 2 ? 1 : -1;
        show();
      });

      // ?t=1234 shows that moment and stays (for frame-by-frame comparison with the recording).
      const fixed = new URLSearchParams(location.search).get("t");
      if (fixed !== null) {
        t = +fixed;
        show();
      } else {
        show();
        // Like the recording: the three icons sit still for a second, then merge.
        setTimeout(() => run(1), 1000);
      }
    </script>
  </body>
</html>

Say hi on WeChat

Liu ZhuoQi's WeChat QR code

Scan it in WeChat, or long-press it on your phone

Mention why you're reaching out, or I might miss it.