惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
A
About on SuperTechFans
量子位
B
Blog RSS Feed
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园_首页
MongoDB | Blog
MongoDB | Blog
小众软件
小众软件
Blog — PlanetScale
Blog — PlanetScale
Microsoft Azure Blog
Microsoft Azure Blog
V
V2EX
Google DeepMind News
Google DeepMind News
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
H
Hackread – Cybersecurity News, Data Breaches, AI and More
G
Google Developers Blog
U
Unit 42
D
DataBreaches.Net
博客园 - Franky
D
Docker
宝玉的分享
宝玉的分享
Y
Y Combinator Blog
月光博客
月光博客
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Hugging Face - Blog
Hugging Face - Blog

LINUX DO - 最新话题

谷歌云盘下载700g数据集,求方法 OpenAI推出了100美元的Pro订阅后,plus的Codex 5小时限额大幅缩水 之前买的super grok居然还没掉 关于CPA认证文件周限 佬们,默认CDK的要求是什么等级啊? 最新版本的微信群聊机器人方案 有没有人知道如何free号没有封,那么是否可以循环使用,因为我看主要是周限 L站改版了?吓我一跳,我以为我浏览器崩了 淘宝这种宽带可信吗,500兆移动宽带月费8元到2099年 docker内部应用访问宿主机mysql和redis时被拒绝connection refuse Erp全栈想转行做Ai有什么推荐的吗 boost有bug 佬们,有没有靠谱点的 Plus 购买渠道 大妈,狗妈用的 lg 服务有源头开源项目吗? 有人有能过验证码打码的嘛 上次帖里好像发过通过大模型来打码的 gpt plus 封号似乎也太快了点,一天就给封号了 按流量/token收费的国产官方AI推荐 我算是知道了为什么Oracle总是ABC了 佬友们帮我分析一下 ChatGPT Team账号只有一个人使用和4个席位邀请满了使用的总额度是一样的吗? gpt-free 10个带rt CPA反代claude是默认1m吗? 我终于敢说我做出来windows上tmux的替代了,目标windows/全平台最强的终端Ai编程工具 claude pro升级max,除了原来的$20,好像还能再领一次$100 关于AI agent的知识框架 独乐乐不如众乐乐,分享一下我的的AI对话程序 佬们自建网站支付问题是怎么解决的 怎么能让gpt模仿claude风格输出 codex free已经死了,下一个会是plus或者team吗 请问chatgpt pro里的fast模式,速度快了,降智吗
怎么 kimi-k2.7-code 的天气卡片测试都没有?我来!天气卡片/...
jantrid (jantrid) · 2026-06-13 · via LINUX DO - 最新话题
结果代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Weather Atelier · 精品天气卡片</title>
  <style>
    :root {
      --bg-deep: #0b1020;
      --bg-mid: #151c32;
      --glass: rgba(255, 255, 255, 0.06);
      --glass-border: rgba(255, 255, 255, 0.12);
      --text: #f8fafc;
      --muted: #94a3b8;
      --accent: #38bdf8;
      --radius: 28px;
      --shadow: 0 24px 60px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.08);
    }

    * { box-sizing: border-box; }

    html, body {
      margin: 0;
      padding: 0;
      min-height: 100vh;
      font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
      background:
        radial-gradient(circle at 20% 10%, rgba(56, 189, 248, 0.12), transparent 40%),
        radial-gradient(circle at 80% 90%, rgba(168, 85, 247, 0.10), transparent 40%),
        linear-gradient(160deg, var(--bg-deep), var(--bg-mid));
      color: var(--text);
      overflow-x: hidden;
    }

    body {
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 48px 20px 64px;
    }

    /* Header */
    header {
      text-align: center;
      margin-bottom: 28px;
      animation: fadeDown 0.8s ease both;
    }

    h1 {
      font-size: clamp(32px, 6vw, 52px);
      font-weight: 700;
      letter-spacing: -0.04em;
      margin: 0 0 8px;
      background: linear-gradient(90deg, #fff 0%, #a5f3fc 50%, #fef08a 100%);
      -webkit-background-clip: text;
      background-clip: text;
      color: transparent;
    }

    .subtitle {
      color: var(--muted);
      font-size: 15px;
      letter-spacing: 0.04em;
    }

    /* Controls */
    .controls {
      display: flex;
      flex-wrap: wrap;
      gap: 12px;
      justify-content: center;
      margin-bottom: 34px;
      animation: fadeUp 0.8s 0.15s ease both;
    }

    .controls button {
      appearance: none;
      border: 1px solid var(--glass-border);
      background: var(--glass);
      color: var(--text);
      padding: 10px 18px;
      border-radius: 999px;
      font-size: 14px;
      font-weight: 500;
      cursor: pointer;
      backdrop-filter: blur(12px);
      -webkit-backdrop-filter: blur(12px);
      transition: transform 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
      box-shadow: 0 4px 18px rgba(0, 0, 0, 0.2);
    }

    .controls button:hover {
      transform: translateY(-2px);
      background: rgba(255, 255, 255, 0.12);
      border-color: rgba(255, 255, 255, 0.25);
    }

    .controls button.active {
      background: rgba(56, 189, 248, 0.22);
      border-color: rgba(56, 189, 248, 0.55);
      box-shadow: 0 0 18px rgba(56, 189, 248, 0.25);
    }

    /* Cards grid */
    .cards {
      display: flex;
      flex-wrap: wrap;
      justify-content: center;
      gap: 24px;
      width: 100%;
      max-width: 1200px;
      animation: fadeUp 0.8s 0.3s ease both;
    }

    .card {
      position: relative;
      width: min(100%, 270px);
      height: 400px;
      border-radius: var(--radius);
      background: var(--glass);
      border: 1px solid var(--glass-border);
      box-shadow: var(--shadow);
      backdrop-filter: blur(18px);
      -webkit-backdrop-filter: blur(18px);
      overflow: hidden;
      transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.35s ease;
    }

    .card:hover {
      transform: translateY(-8px) scale(1.02);
      box-shadow: 0 32px 72px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
    }

    /* Scene wrapper */
    .scene {
      position: absolute;
      inset: 0;
      opacity: 0;
      visibility: hidden;
      z-index: 0;
      transition: opacity 0.55s ease, visibility 0.55s ease;
      overflow: hidden;
      border-radius: var(--radius);
    }

    .card[data-weather="wind"] .scene[data-scene="wind"],
    .card[data-weather="rain"] .scene[data-scene="rain"],
    .card[data-weather="sunny"] .scene[data-scene="sunny"],
    .card[data-weather="snow"] .scene[data-scene="snow"] {
      opacity: 1;
      visibility: visible;
      z-index: 1;
    }

    /* Info overlay */
    .info {
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      padding: 20px 22px 22px;
      background: linear-gradient(to top, rgba(2, 6, 23, 0.75), transparent);
      z-index: 10;
      display: flex;
      justify-content: space-between;
      align-items: flex-end;
    }

    .location {
      font-size: 15px;
      font-weight: 600;
      letter-spacing: 0.02em;
    }

    .condition {
      font-size: 12px;
      color: var(--muted);
      margin-top: 3px;
    }

    .temp {
      font-size: 36px;
      font-weight: 300;
      line-height: 1;
      letter-spacing: -0.04em;
    }

    .temp sup {
      font-size: 16px;
      font-weight: 400;
      margin-left: 2px;
    }

    /* ---------- Wind scene ---------- */
    .scene[data-scene="wind"] {
      background: linear-gradient(180deg, #3b82a0 0%, #1e3a5f 100%);
    }

    .cloud {
      position: absolute;
      background: rgba(255, 255, 255, 0.9);
      border-radius: 999px;
      filter: blur(0.5px);
      opacity: 0.92;
    }

    .cloud::before, .cloud::after {
      content: "";
      position: absolute;
      background: inherit;
      border-radius: 50%;
    }

    .cloud-a {
      width: 120px; height: 42px;
      top: 16%; left: -40%;
      animation: driftCloud 10s linear infinite;
    }
    .cloud-a::before { width: 56px; height: 56px; top: -28px; left: 16px; }
    .cloud-a::after { width: 42px; height: 42px; top: -18px; left: 60px; }

    .cloud-b {
      width: 90px; height: 32px;
      top: 34%; left: -30%;
      opacity: 0.75;
      animation: driftCloud 14s linear infinite;
      animation-delay: -4s;
    }
    .cloud-b::before { width: 44px; height: 44px; top: -22px; left: 12px; }
    .cloud-b::after { width: 34px; height: 34px; top: -14px; left: 48px; }

    .cloud-c {
      width: 150px; height: 48px;
      top: 8%; left: -50%;
      opacity: 0.55;
      animation: driftCloud 18s linear infinite;
      animation-delay: -8s;
    }
    .cloud-c::before { width: 70px; height: 70px; top: -34px; left: 22px; }
    .cloud-c::after { width: 52px; height: 52px; top: -22px; left: 78px; }

    @keyframes driftCloud {
      0% { transform: translateX(0); }
      100% { transform: translateX(420px); }
    }

    .tree {
      position: absolute;
      bottom: 58px;
      left: 50%;
      width: 110px;
      height: 160px;
      transform: translateX(-50%);
      transform-origin: 50% 100%;
      animation: sway 2.6s ease-in-out infinite alternate;
      z-index: 2;
    }

    .tree svg { width: 100%; height: 100%; display: block; filter: drop-shadow(0 8px 12px rgba(0,0,0,0.25)); }

    @keyframes sway {
      0% { transform: translateX(-50%) rotate(-4deg); }
      100% { transform: translateX(-50%) rotate(5deg); }
    }

    .wind-line {
      position: absolute;
      height: 2px;
      border-radius: 2px;
      background: linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent);
      opacity: 0;
      animation: windSweep 1.8s linear infinite;
    }

    .wind-line:nth-child(1) { width: 90px; top: 30%; left: -30%; animation-delay: 0s; }
    .wind-line:nth-child(2) { width: 70px; top: 44%; left: -25%; animation-delay: 0.4s; }
    .wind-line:nth-child(3) { width: 110px; top: 58%; left: -35%; animation-delay: 0.8s; }
    .wind-line:nth-child(4) { width: 60px; top: 70%; left: -20%; animation-delay: 1.2s; }
    .wind-line:nth-child(5) { width: 100px; top: 22%; left: -32%; animation-delay: 1.6s; }

    @keyframes windSweep {
      0% { transform: translateX(0); opacity: 0; }
      20% { opacity: 0.8; }
      80% { opacity: 0.6; }
      100% { transform: translateX(360px); opacity: 0; }
    }

    /* ---------- Rain scene ---------- */
    .scene[data-scene="rain"] {
      background: linear-gradient(180deg, #334155 0%, #0f172a 100%);
    }

    .rain-cloud {
      position: absolute;
      background: #475569;
      border-radius: 999px;
      top: -20px;
      filter: blur(1px);
    }

    .rain-cloud::before, .rain-cloud::after {
      content: "";
      position: absolute;
      background: inherit;
      border-radius: 50%;
    }

    .rain-cloud-a { width: 160px; height: 54px; left: 10%; animation: rainCloudBob 4s ease-in-out infinite alternate; }
    .rain-cloud-a::before { width: 78px; height: 78px; top: -38px; left: 18px; }
    .rain-cloud-a::after { width: 58px; height: 58px; top: -26px; left: 82px; }

    .rain-cloud-b { width: 120px; height: 42px; right: 0; top: -8px; opacity: 0.85; animation: rainCloudBob 5s ease-in-out infinite alternate-reverse; }
    .rain-cloud-b::before { width: 60px; height: 60px; top: -30px; left: 14px; }
    .rain-cloud-b::after { width: 44px; height: 44px; top: -20px; left: 62px; }

    @keyframes rainCloudBob {
      0% { transform: translateX(0); }
      100% { transform: translateX(14px); }
    }

    .ground {
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      height: 64px;
      background: linear-gradient(to top, #020617, transparent);
      z-index: 2;
    }

    .puddle {
      position: absolute;
      bottom: 10px;
      border-radius: 50%;
      background: radial-gradient(ellipse at center, rgba(148, 163, 184, 0.35), rgba(148, 163, 184, 0.05) 70%, transparent);
      animation: ripple 2.2s ease-in-out infinite;
    }

    .puddle-a { width: 70px; height: 18px; left: 18%; animation-delay: 0s; }
    .puddle-b { width: 50px; height: 12px; right: 22%; animation-delay: 0.8s; }
    .puddle-c { width: 36px; height: 10px; left: 56%; animation-delay: 1.5s; }

    @keyframes ripple {
      0%, 100% { transform: scale(0.85); opacity: 0.55; }
      50% { transform: scale(1.15); opacity: 0.85; }
    }

    .rain-canvas {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      z-index: 3;
      pointer-events: none;
    }

    /* ---------- Sunny scene ---------- */
    .scene[data-scene="sunny"] {
      background:
        radial-gradient(circle at 75% 18%, rgba(251, 191, 36, 0.55), transparent 45%),
        linear-gradient(180deg, #38bdf8 0%, #0ea5e9 100%);
    }

    .sun {
      position: absolute;
      top: 32px;
      right: 32px;
      width: 84px;
      height: 84px;
      border-radius: 50%;
      background: radial-gradient(circle at 35% 35%, #fef3c7, #fbbf24 55%, #f59e0b 100%);
      box-shadow:
        0 0 40px rgba(251, 191, 36, 0.55),
        0 0 90px rgba(245, 158, 11, 0.35),
        inset -6px -6px 16px rgba(217, 119, 6, 0.25);
      animation: sunPulse 3.5s ease-in-out infinite alternate;
      z-index: 2;
    }

    @keyframes sunPulse {
      0% { transform: scale(1); box-shadow: 0 0 40px rgba(251, 191, 36, 0.55), 0 0 90px rgba(245, 158, 11, 0.35); }
      100% { transform: scale(1.08); box-shadow: 0 0 60px rgba(251, 191, 36, 0.75), 0 0 130px rgba(245, 158, 11, 0.5); }
    }

    .sun-rays {
      position: absolute;
      top: 50%;
      left: 50%;
      width: 220px;
      height: 220px;
      transform: translate(-50%, -50%);
      border-radius: 50%;
      background: repeating-conic-gradient(
        from 0deg,
        rgba(255, 255, 255, 0.12) 0deg 8deg,
        transparent 8deg 16deg
      );
      animation: raySpin 18s linear infinite;
      z-index: 1;
    }

    @keyframes raySpin {
      from { transform: translate(-50%, -50%) rotate(0deg); }
      to { transform: translate(-50%, -50%) rotate(360deg); }
    }

    .glow-orb {
      position: absolute;
      border-radius: 50%;
      filter: blur(30px);
      opacity: 0.5;
      animation: floatOrb 8s ease-in-out infinite alternate;
    }

    .glow-orb-a {
      width: 120px; height: 120px;
      background: rgba(253, 224, 71, 0.35);
      top: 20%; left: 15%;
    }

    .glow-orb-b {
      width: 90px; height: 90px;
      background: rgba(255, 255, 255, 0.25);
      bottom: 25%; left: 30%;
      animation-delay: -3s;
    }

    @keyframes floatOrb {
      0% { transform: translate(0, 0); }
      100% { transform: translate(12px, -18px); }
    }

    /* ---------- Snow scene ---------- */
    .scene[data-scene="snow"] {
      background: linear-gradient(180deg, #1e1b4b 0%, #312e81 60%, #1e293b 100%);
    }

    .moon {
      position: absolute;
      top: 32px;
      left: 32px;
      width: 56px;
      height: 56px;
      border-radius: 50%;
      background: radial-gradient(circle at 35% 35%, #f8fafc, #e2e8f0 60%, #94a3b8 100%);
      box-shadow: 0 0 40px rgba(226, 232, 240, 0.35);
      z-index: 2;
    }

    .snow-hill {
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      height: 90px;
      background: linear-gradient(to top, #e0f2fe 0%, rgba(224, 242, 254, 0.25) 70%, transparent);
      border-radius: 50% 50% 0 0 / 30px 30px 0 0;
      z-index: 2;
    }

    .snow-hill::before {
      content: "";
      position: absolute;
      bottom: -10px;
      right: -20%;
      width: 140%;
      height: 80px;
      background: linear-gradient(to top, #f1f5f9 0%, rgba(241, 245, 249, 0.2) 70%, transparent);
      border-radius: 50% 50% 0 0 / 30px 30px 0 0;
      opacity: 0.7;
    }

    .snow-tree {
      position: absolute;
      bottom: 46px;
      right: 18%;
      width: 70px;
      height: 110px;
      z-index: 3;
      transform-origin: 50% 100%;
      animation: gentleSway 4s ease-in-out infinite alternate;
    }

    .snow-tree svg { width: 100%; height: 100%; display: block; filter: drop-shadow(0 6px 10px rgba(0,0,0,0.3)); }

    @keyframes gentleSway {
      0% { transform: rotate(-2deg); }
      100% { transform: rotate(2deg); }
    }

    .snow-canvas {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      z-index: 4;
      pointer-events: none;
    }

    /* Utility animations */
    @keyframes fadeDown {
      from { opacity: 0; transform: translateY(-16px); }
      to { opacity: 1; transform: translateY(0); }
    }

    @keyframes fadeUp {
      from { opacity: 0; transform: translateY(16px); }
      to { opacity: 1; transform: translateY(0); }
    }

    /* Mobile: keep side-by-side feel when possible */
    @media (max-width: 600px) {
      .cards { gap: 18px; }
      .card { height: 360px; }
    }
  </style>
</head>
<body>
  <header>
    <h1>Weather Atelier</h1>
    <p class="subtitle">精品天气卡片 · CSS & Canvas 动画演示 · 点击卡片可单独切换</p>
  </header>

  <nav class="controls" aria-label="天气切换">
    <button data-set="wind" aria-pressed="false">🍃 风力</button>
    <button data-set="rain" aria-pressed="false">🌧 降雨</button>
    <button data-set="sunny" aria-pressed="false">☀️ 晴天</button>
    <button data-set="snow" aria-pressed="false">❄️ 下雪</button>
    <button data-set="random" aria-pressed="false">🔀 随机</button>
    <button data-set="cycle" aria-pressed="false">▶ 自动轮换</button>
  </nav>

  <section class="cards" aria-label="天气卡片">
    <!-- Card 1 -->
    <article class="card" id="card-1" data-weather="wind">
      <div class="scene" data-scene="wind" aria-hidden="true">
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="cloud cloud-c"></div>
        <div class="cloud cloud-a"></div>
        <div class="cloud cloud-b"></div>
        <div class="tree">
          <svg viewBox="0 0 100 140" preserveAspectRatio="xMidYMax">
            <path d="M50 135 L52 85 L75 60 L55 70 L68 35 L52 48 L55 5 L45 5 L48 48 L32 35 L45 70 L25 60 L48 85 L50 135Z" fill="#0f3d2e"/>
            <rect x="45" y="130" width="10" height="12" rx="2" fill="#3f2e18"/>
          </svg>
        </div>
      </div>

      <div class="scene" data-scene="rain" aria-hidden="true">
        <div class="rain-cloud rain-cloud-a"></div>
        <div class="rain-cloud rain-cloud-b"></div>
        <canvas class="rain-canvas"></canvas>
        <div class="ground"></div>
        <div class="puddle puddle-a"></div>
        <div class="puddle puddle-b"></div>
        <div class="puddle puddle-c"></div>
      </div>

      <div class="scene" data-scene="sunny" aria-hidden="true">
        <div class="glow-orb glow-orb-a"></div>
        <div class="glow-orb glow-orb-b"></div>
        <div class="sun-rays"></div>
        <div class="sun"></div>
      </div>

      <div class="scene" data-scene="snow" aria-hidden="true">
        <div class="moon"></div>
        <div class="snow-hill"></div>
        <div class="snow-tree">
          <svg viewBox="0 0 60 100" preserveAspectRatio="xMidYMax">
            <path d="M30 95 L32 62 L48 42 L34 50 L44 22 L32 32 L35 2 L25 2 L28 32 L16 22 L26 50 L12 42 L28 62 L30 95Z" fill="#1e3a8a"/>
            <rect x="26" y="90" width="8" height="12" rx="2" fill="#172554"/>
          </svg>
        </div>
        <canvas class="snow-canvas"></canvas>
      </div>

      <div class="info">
        <div>
          <div class="location">青岛</div>
          <div class="condition">微风徐徐</div>
        </div>
        <div class="temp">21<sup>°</sup></div>
      </div>
    </article>

    <!-- Card 2 -->
    <article class="card" id="card-2" data-weather="rain">
      <div class="scene" data-scene="wind" aria-hidden="true">
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="cloud cloud-c"></div>
        <div class="cloud cloud-a"></div>
        <div class="cloud cloud-b"></div>
        <div class="tree">
          <svg viewBox="0 0 100 140" preserveAspectRatio="xMidYMax">
            <path d="M50 135 L52 85 L75 60 L55 70 L68 35 L52 48 L55 5 L45 5 L48 48 L32 35 L45 70 L25 60 L48 85 L50 135Z" fill="#0f3d2e"/>
            <rect x="45" y="130" width="10" height="12" rx="2" fill="#3f2e18"/>
          </svg>
        </div>
      </div>

      <div class="scene" data-scene="rain" aria-hidden="true">
        <div class="rain-cloud rain-cloud-a"></div>
        <div class="rain-cloud rain-cloud-b"></div>
        <canvas class="rain-canvas"></canvas>
        <div class="ground"></div>
        <div class="puddle puddle-a"></div>
        <div class="puddle puddle-b"></div>
        <div class="puddle puddle-c"></div>
      </div>

      <div class="scene" data-scene="sunny" aria-hidden="true">
        <div class="glow-orb glow-orb-a"></div>
        <div class="glow-orb glow-orb-b"></div>
        <div class="sun-rays"></div>
        <div class="sun"></div>
      </div>

      <div class="scene" data-scene="snow" aria-hidden="true">
        <div class="moon"></div>
        <div class="snow-hill"></div>
        <div class="snow-tree">
          <svg viewBox="0 0 60 100" preserveAspectRatio="xMidYMax">
            <path d="M30 95 L32 62 L48 42 L34 50 L44 22 L32 32 L35 2 L25 2 L28 32 L16 22 L26 50 L12 42 L28 62 L30 95Z" fill="#1e3a8a"/>
            <rect x="26" y="90" width="8" height="12" rx="2" fill="#172554"/>
          </svg>
        </div>
        <canvas class="snow-canvas"></canvas>
      </div>

      <div class="info">
        <div>
          <div class="location">上海</div>
          <div class="condition">中雨</div>
        </div>
        <div class="temp">18<sup>°</sup></div>
      </div>
    </article>

    <!-- Card 3 -->
    <article class="card" id="card-3" data-weather="sunny">
      <div class="scene" data-scene="wind" aria-hidden="true">
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="cloud cloud-c"></div>
        <div class="cloud cloud-a"></div>
        <div class="cloud cloud-b"></div>
        <div class="tree">
          <svg viewBox="0 0 100 140" preserveAspectRatio="xMidYMax">
            <path d="M50 135 L52 85 L75 60 L55 70 L68 35 L52 48 L55 5 L45 5 L48 48 L32 35 L45 70 L25 60 L48 85 L50 135Z" fill="#0f3d2e"/>
            <rect x="45" y="130" width="10" height="12" rx="2" fill="#3f2e18"/>
          </svg>
        </div>
      </div>

      <div class="scene" data-scene="rain" aria-hidden="true">
        <div class="rain-cloud rain-cloud-a"></div>
        <div class="rain-cloud rain-cloud-b"></div>
        <canvas class="rain-canvas"></canvas>
        <div class="ground"></div>
        <div class="puddle puddle-a"></div>
        <div class="puddle puddle-b"></div>
        <div class="puddle puddle-c"></div>
      </div>

      <div class="scene" data-scene="sunny" aria-hidden="true">
        <div class="glow-orb glow-orb-a"></div>
        <div class="glow-orb glow-orb-b"></div>
        <div class="sun-rays"></div>
        <div class="sun"></div>
      </div>

      <div class="scene" data-scene="snow" aria-hidden="true">
        <div class="moon"></div>
        <div class="snow-hill"></div>
        <div class="snow-tree">
          <svg viewBox="0 0 60 100" preserveAspectRatio="xMidYMax">
            <path d="M30 95 L32 62 L48 42 L34 50 L44 22 L32 32 L35 2 L25 2 L28 32 L16 22 L26 50 L12 42 L28 62 L30 95Z" fill="#1e3a8a"/>
            <rect x="26" y="90" width="8" height="12" rx="2" fill="#172554"/>
          </svg>
        </div>
        <canvas class="snow-canvas"></canvas>
      </div>

      <div class="info">
        <div>
          <div class="location">三亚</div>
          <div class="condition">晴朗</div>
        </div>
        <div class="temp">29<sup>°</sup></div>
      </div>
    </article>

    <!-- Card 4 -->
    <article class="card" id="card-4" data-weather="snow">
      <div class="scene" data-scene="wind" aria-hidden="true">
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="wind-line"></div>
        <div class="cloud cloud-c"></div>
        <div class="cloud cloud-a"></div>
        <div class="cloud cloud-b"></div>
        <div class="tree">
          <svg viewBox="0 0 100 140" preserveAspectRatio="xMidYMax">
            <path d="M50 135 L52 85 L75 60 L55 70 L68 35 L52 48 L55 5 L45 5 L48 48 L32 35 L45 70 L25 60 L48 85 L50 135Z" fill="#0f3d2e"/>
            <rect x="45" y="130" width="10" height="12" rx="2" fill="#3f2e18"/>
          </svg>
        </div>
      </div>

      <div class="scene" data-scene="rain" aria-hidden="true">
        <div class="rain-cloud rain-cloud-a"></div>
        <div class="rain-cloud rain-cloud-b"></div>
        <canvas class="rain-canvas"></canvas>
        <div class="ground"></div>
        <div class="puddle puddle-a"></div>
        <div class="puddle puddle-b"></div>
        <div class="puddle puddle-c"></div>
      </div>

      <div class="scene" data-scene="sunny" aria-hidden="true">
        <div class="glow-orb glow-orb-a"></div>
        <div class="glow-orb glow-orb-b"></div>
        <div class="sun-rays"></div>
        <div class="sun"></div>
      </div>

      <div class="scene" data-scene="snow" aria-hidden="true">
        <div class="moon"></div>
        <div class="snow-hill"></div>
        <div class="snow-tree">
          <svg viewBox="0 0 60 100" preserveAspectRatio="xMidYMax">
            <path d="M30 95 L32 62 L48 42 L34 50 L44 22 L32 32 L35 2 L25 2 L28 32 L16 22 L26 50 L12 42 L28 62 L30 95Z" fill="#1e3a8a"/>
            <rect x="26" y="90" width="8" height="12" rx="2" fill="#172554"/>
          </svg>
        </div>
        <canvas class="snow-canvas"></canvas>
      </div>

      <div class="info">
        <div>
          <div class="location">哈尔滨</div>
          <div class="condition">大雪</div>
        </div>
        <div class="temp">-6<sup>°</sup></div>
      </div>
    </article>
  </section>

  <script>
    (function () {
      'use strict';

      const WEATHERS = ['wind', 'rain', 'sunny', 'snow'];
      const cards = Array.from(document.querySelectorAll('.card'));
      const buttons = document.querySelectorAll('.controls button');
      const states = new Map();
      let cycleTimer = null;
      let rafId = null;
      let running = true;

      // ---------- Canvas helpers ----------
      function sizeCanvas(canvas) {
        const rect = canvas.getBoundingClientRect();
        const dpr = window.devicePixelRatio || 1;
        canvas.width = Math.max(1, Math.floor(rect.width * dpr));
        canvas.height = Math.max(1, Math.floor(rect.height * dpr));
        const ctx = canvas.getContext('2d');
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        return { width: rect.width, height: rect.height };
      }

      function initRain(card) {
        let s = states.get(card);
        if (!s) { s = {}; states.set(card, s); }
        if (s.rainActive) return;

        const canvas = card.querySelector('.rain-canvas');
        if (!canvas) return;
        const dims = sizeCanvas(canvas);
        const ctx = canvas.getContext('2d');
        const drops = [];
        const count = Math.floor((dims.width * dims.height) / 2600);
        for (let i = 0; i < count; i++) {
          drops.push({
            x: Math.random() * dims.width,
            y: Math.random() * dims.height,
            len: 10 + Math.random() * 16,
            speed: 10 + Math.random() * 8,
            opacity: 0.25 + Math.random() * 0.35
          });
        }
        s.rain = { canvas, ctx, dims, drops, active: true };
        s.rainActive = true;
      }

      function stopRain(card) {
        const s = states.get(card);
        if (s && s.rain) { s.rain.active = false; s.rainActive = false; }
      }

      function drawRain(s) {
        const { ctx, dims, drops } = s.rain;
        ctx.clearRect(0, 0, dims.width, dims.height);
        ctx.strokeStyle = 'rgba(186, 230, 253, 0.55)';
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        for (const d of drops) {
          ctx.moveTo(d.x, d.y);
          ctx.lineTo(d.x - 1.5, d.y + d.len);
          d.y += d.speed;
          d.x -= 0.35;
          if (d.y > dims.height) {
            d.y = -d.len;
            d.x = Math.random() * dims.width;
          }
        }
        ctx.stroke();
      }

      function initSnow(card) {
        let s = states.get(card);
        if (!s) { s = {}; states.set(card, s); }
        if (s.snowActive) return;

        const canvas = card.querySelector('.snow-canvas');
        if (!canvas) return;
        const dims = sizeCanvas(canvas);
        const ctx = canvas.getContext('2d');
        const flakes = [];
        const count = Math.floor((dims.width * dims.height) / 2400);
        for (let i = 0; i < count; i++) {
          flakes.push({
            x: Math.random() * dims.width,
            y: Math.random() * dims.height,
            r: 1.2 + Math.random() * 2.2,
            speed: 0.6 + Math.random() * 1.2,
            sway: Math.random() * Math.PI * 2,
            swaySpeed: 0.01 + Math.random() * 0.02,
            opacity: 0.5 + Math.random() * 0.5
          });
        }
        s.snow = { canvas, ctx, dims, flakes, active: true };
        s.snowActive = true;
      }

      function stopSnow(card) {
        const s = states.get(card);
        if (s && s.snow) { s.snow.active = false; s.snowActive = false; }
      }

      function drawSnow(s) {
        const { ctx, dims, flakes } = s.snow;
        ctx.clearRect(0, 0, dims.width, dims.height);
        for (const f of flakes) {
          ctx.beginPath();
          ctx.fillStyle = `rgba(255, 255, 255, ${f.opacity})`;
          ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2);
          ctx.fill();
          f.y += f.speed;
          f.sway += f.swaySpeed;
          f.x += Math.sin(f.sway) * 0.4;
          if (f.y > dims.height) {
            f.y = -4;
            f.x = Math.random() * dims.width;
          }
          if (f.x > dims.width) f.x = 0;
          if (f.x < 0) f.x = dims.width;
        }
      }

      function updateActiveSystems() {
        cards.forEach(card => {
          const weather = card.dataset.weather;
          if (weather === 'rain') initRain(card); else stopRain(card);
          if (weather === 'snow') initSnow(card); else stopSnow(card);
        });
      }

      function tick() {
        if (!running) return;
        for (const s of states.values()) {
          if (s.rain && s.rain.active) drawRain(s);
          if (s.snow && s.snow.active) drawSnow(s);
        }
        rafId = requestAnimationFrame(tick);
      }

      // ---------- Weather switching ----------
      function setCardWeather(card, weather) {
        card.dataset.weather = weather;
        const loc = card.querySelector('.location').textContent;
        const cond = card.querySelector('.condition');
        const temp = card.querySelector('.temp');
        const map = {
          wind: { c: '微风', t: '21' },
          rain: { c: '中雨', t: '18' },
          sunny: { c: '晴朗', t: '29' },
          snow: { c: '大雪', t: '-6' }
        };
        // Keep per-city defaults if matching the original default card mapping
        const presets = {
          '青岛': { wind: '微风', rain: '阵雨', sunny: '晴热', snow: '小雪' },
          '上海': { wind: '清风', rain: '中雨', sunny: '多云', snow: '雨夹雪' },
          '三亚': { wind: '海风', rain: '雷阵雨', sunny: '晴朗', snow: '无雪' },
          '哈尔滨': { wind: '北风', rain: '冻雨', sunny: '晴朗', snow: '大雪' }
        };
        const cityPresets = presets[loc] || {};
        cond.textContent = cityPresets[weather] || map[weather].c;
        temp.innerHTML = `${map[weather].t}<sup>°</sup>`;
        updateActiveSystems();
      }

      function setAllWeather(weather) {
        cards.forEach(card => setCardWeather(card, weather));
        updateButtons(weather);
      }

      function randomize() {
        cards.forEach(card => {
          const w = WEATHERS[Math.floor(Math.random() * WEATHERS.length)];
          setCardWeather(card, w);
        });
        updateButtons(null);
      }

      function updateButtons(activeType) {
        buttons.forEach(btn => {
          const type = btn.dataset.set;
          const isActive = type === activeType;
          btn.classList.toggle('active', isActive);
          btn.setAttribute('aria-pressed', String(isActive));
        });
      }

      function startCycle() {
        if (cycleTimer) return;
        let idx = 0;
        setAllWeather(WEATHERS[idx]);
        cycleTimer = setInterval(() => {
          idx = (idx + 1) % WEATHERS.length;
          setAllWeather(WEATHERS[idx]);
        }, 3200);
      }

      function stopCycle() {
        if (cycleTimer) { clearInterval(cycleTimer); cycleTimer = null; }
      }

      // ---------- Events ----------
      buttons.forEach(btn => {
        btn.addEventListener('click', () => {
          const type = btn.dataset.set;
          stopCycle();
          if (type === 'cycle') {
            startCycle();
          } else if (type === 'random') {
            randomize();
          } else {
            setAllWeather(type);
          }
        });
      });

      window.addEventListener('resize', () => {
        // Resize canvases and re-init active systems
        states.forEach(s => {
          if (s.rain) {
            s.rain.dims = sizeCanvas(s.rain.canvas);
            s.rain.drops = [];
            const count = Math.floor((s.rain.dims.width * s.rain.dims.height) / 2600);
            for (let i = 0; i < count; i++) {
              s.rain.drops.push({
                x: Math.random() * s.rain.dims.width,
                y: Math.random() * s.rain.dims.height,
                len: 10 + Math.random() * 16,
                speed: 10 + Math.random() * 8,
                opacity: 0.25 + Math.random() * 0.35
              });
            }
          }
          if (s.snow) {
            s.snow.dims = sizeCanvas(s.snow.canvas);
            s.snow.flakes = [];
            const count = Math.floor((s.snow.dims.width * s.snow.dims.height) / 2400);
            for (let i = 0; i < count; i++) {
              s.snow.flakes.push({
                x: Math.random() * s.snow.dims.width,
                y: Math.random() * s.snow.dims.height,
                r: 1.2 + Math.random() * 2.2,
                speed: 0.6 + Math.random() * 1.2,
                sway: Math.random() * Math.PI * 2,
                swaySpeed: 0.01 + Math.random() * 0.02,
                opacity: 0.5 + Math.random() * 0.5
              });
            }
          }
        });
      });

      document.addEventListener('visibilitychange', () => {
        running = !document.hidden;
        if (running && !rafId) tick();
      });

      cards.forEach(card => {
        card.title = '点击切换天气';
        card.style.cursor = 'pointer';
        card.addEventListener('click', () => {
          stopCycle();
          const current = card.dataset.weather;
          const next = WEATHERS[(WEATHERS.indexOf(current) + 1) % WEATHERS.length];
          setCardWeather(card, next);
          updateButtons(null);
        });
      });

      // ---------- Init ----------
      updateActiveSystems();
      tick();
    })();
  </script>
</body>
</html>

结果代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>SVG Plant Growth</title>
  <style>
    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }
    html, body {
      width: 100%;
      height: 100%;
      overflow: hidden;
      background: linear-gradient(180deg, #0f172a 0%, #1e293b 60%, #0b3d2e 100%);
    }
    svg {
      display: block;
      width: 100vw;
      height: 100vh;
    }
  </style>
</head>
<body>
  <svg id="garden" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMax slice">
    <defs>
      <linearGradient id="stemGrad" x1="0%" y1="0%" x2="100%" y2="0%">
        <stop offset="0%" stop-color="#365314" />
        <stop offset="50%" stop-color="#65a30d" />
        <stop offset="100%" stop-color="#365314" />
      </linearGradient>
      <radialGradient id="leafGrad" cx="30%" cy="30%" r="80%">
        <stop offset="0%" stop-color="#a3e635" />
        <stop offset="60%" stop-color="#4d7c0f" />
        <stop offset="100%" stop-color="#1a2e05" />
      </radialGradient>
    </defs>
    <g id="ground" transform="translate(0,0)">
      <ellipse cx="0" cy="0" rx="220" ry="28" fill="rgba(20, 83, 45, 0.45)" />
      <ellipse cx="0" cy="0" rx="150" ry="18" fill="rgba(34, 197, 94, 0.25)" />
    </g>
    <g id="plant" transform="translate(0,0)"></g>
  </svg>

  <script>
    const svg = document.getElementById('garden');
    const plantGroup = document.getElementById('plant');
    const groundGroup = document.getElementById('ground');

    function resize() {
      const w = window.innerWidth;
      const h = window.innerHeight;
      svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
      groundGroup.setAttribute('transform', `translate(${w / 2}, ${h - 20})`);
      plantGroup.setAttribute('transform', `translate(${w / 2}, ${h - 30})`);
    }
    window.addEventListener('resize', resize);
    resize();

    // -------------------------------------------------------------------------
    // Math helpers
    // -------------------------------------------------------------------------
    const rand = (min, max) => min + Math.random() * (max - min);
    const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
    const lerp = (a, b, t) => a + (b - a) * t;
    const smoothstep = (a, b, t) => {
      const x = clamp((t - a) / (b - a), 0, 1);
      return x * x * (3 - 2 * x);
    };

    function curlShape(t) {
      // A leaf outline in local coords: t in [0..1] along length.
      // Returns width factor (0 at base/tip, widest near the base).
      if (t <= 0 || t >= 1) return 0;
      if (t < 0.15) return t / 0.15;
      const u = (t - 0.15) / 0.85;
      return (1 - u) * (1 - u) * (1 + 2 * u); // smooth cubic falloff
    }

    // -------------------------------------------------------------------------
    // Leaf: springy, unfurls, sways in the wind
    // -------------------------------------------------------------------------
    class Leaf {
      constructor(side, nodeIndex, stemLen, nodeY, curvature, lean, xOffset, group) {
        this.side = side; // -1 left, +1 right
        this.nodeIndex = nodeIndex;
        this.stemLen = stemLen;
        this.nodeY = nodeY;
        this.curvature = curvature;
        this.lean = lean;
        this.xOffset = xOffset;
        this.group = group;
        this.windPhase = 0;

        this.length = rand(35, 75) * (1 + nodeIndex / 28);
        this.baseAngle = (side === 1 ? rand(310, 340) : rand(200, 230)) * (Math.PI / 180);
        this.naturalAngle = this.baseAngle;
        this.unfurlDelay = rand(0, 0.25);        // seconds after node visible before unfurling
        this.unfurlSpeed = rand(0.55, 1.1);
        this.unfurl = 0;                         // 0..1
        this.scale = rand(0.75, 1.05);

        // Spring state for wind/sway
        this.angle = this.naturalAngle + rand(-0.2, 0.2);
        this.vel = 0;

        this.el = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        this.el.setAttribute('fill', 'url(#leafGrad)');
        this.el.setAttribute('stroke', 'rgba(26, 46, 5, 0.35)');
        this.el.setAttribute('stroke-width', '0.6');
        this.el.setAttribute('opacity', '0');
        this.group.appendChild(this.el);
      }

      stemXAt(t) {
        const sway = Math.sin(this.windPhase * 0.7 + this.xOffset * 0.02) * 18 +
                     Math.sin(this.windPhase * 1.4) * 6;
        return Math.sin(t * Math.PI * 0.9) * this.curvature * 40 +
               t * this.lean * this.stemLen * 0.35 +
               t * t * sway;
      }

      update(dt, growProgress, windPhase) {
        this.windPhase = windPhase;
        // Determine when this leaf is allowed to start unfurling.
        // nodeY is relative stem length 0..1.
        const nodeVisibleAt = this.nodeY * 0.85; // stem reaches this leaf
        const startAt = nodeVisibleAt + this.unfurlDelay / 12; // stretch over ~12s cycle

        const localT = clamp((growProgress - startAt) * this.unfurlSpeed * 4, 0, 1);
        this.unfurl = smoothstep(0, 1, localT);

        // Wind field: layered sines with a slow gust envelope.
        const gust = 1 + 0.35 * Math.sin(windPhase * 0.17 + this.nodeY * 3);
        const wind =
          Math.sin(windPhase + this.nodeY * 2.5) * 0.12 +
          Math.sin(windPhase * 2.3 + this.nodeY * 6) * 0.05 +
          Math.sin(windPhase * 0.4 + this.nodeIndex) * 0.08;
        const target = this.naturalAngle + wind * gust * this.side;

        // Spring toward target.
        const k = 18;
        const damping = 0.82;
        const acc = (target - this.angle) * k;
        this.vel += acc * dt;
        this.vel *= damping;
        this.angle += this.vel * dt;

        // Curl amount reduces as leaf unfurls.
        const curl = lerp(-1.9, -0.18, this.unfurl);
        const d = this.buildPath(curl);
        this.el.setAttribute('d', d);

        const opacity = clamp(this.unfurl * 1.3, 0, 1);
        this.el.setAttribute('opacity', opacity.toFixed(3));
      }

      buildPath(curl) {
        // Local leaf space: origin at petiole base, growing along +x.
        // We then rotate/transform via SVG transform.
        const segs = 24;
        const pts = [];
        for (let i = 0; i <= segs; i++) {
          const t = i / segs;
          const x = t * this.length;
          const y = Math.sin(t * Math.PI * curl) * this.length * 0.22;
          pts.push([x, y]);
        }

        // Build top and bottom edges using width profile.
        const top = [];
        const bot = [];
        for (let i = 0; i <= segs; i++) {
          const t = i / segs;
          const w = curlShape(t) * this.length * 0.22;
          const nx = pts[i][1] - (pts[i + 1]?.[1] ?? pts[i][1]);
          const ny = -(pts[i + 1]?.[0] ?? pts[i][0]) + pts[i][0];
          const mag = Math.hypot(nx, ny) || 1;
          const ux = nx / mag;
          const uy = ny / mag;
          top.push([pts[i][0] + ux * w, pts[i][1] + uy * w]);
          bot.push([pts[i][0] - ux * w, pts[i][1] - uy * w]);
        }

        let d = `M ${top[0][0].toFixed(2)} ${top[0][1].toFixed(2)}`;
        for (let i = 1; i < top.length; i++) {
          d += ` L ${top[i][0].toFixed(2)} ${top[i][1].toFixed(2)}`;
        }
        for (let i = bot.length - 1; i >= 0; i--) {
          d += ` L ${bot[i][0].toFixed(2)} ${bot[i][1].toFixed(2)}`;
        }
        d += ' Z';
        return d;
      }

      draw() {
        const s = smoothstep(0, 0.25, this.unfurl) * this.scale;
        const x = this.stemXAt(this.nodeY);
        this.el.setAttribute(
          'transform',
          `translate(${x.toFixed(2)}, ${(-this.nodeY * this.stemLen).toFixed(2)}) rotate(${(this.angle * 180 / Math.PI).toFixed(2)}) scale(${s.toFixed(3)})`
        );
      }

      remove() {
        if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
      }
    }

    // -------------------------------------------------------------------------
    // Plant
    // -------------------------------------------------------------------------
    class Plant {
      constructor(group) {
        this.group = group;
        this.reset();
      }

      reset() {
        // Clear old elements.
        while (this.group.firstChild) this.group.removeChild(this.group.firstChild);
        this.leaves = [];
        this.nodes = [];

        // Procedural stem geometry.
        this.height = rand(260, 420);
        this.nodeCount = 10 + Math.floor(Math.random() * 7); // 10..16
        this.curvature = rand(-0.35, 0.35);
        this.lean = rand(-0.12, 0.12);
        this.xOffset = rand(-50, 50);

        // Build node positions along stem.
        for (let i = 0; i < this.nodeCount; i++) {
          const y = (i + 1) / (this.nodeCount + 1);
          const side = i % 2 === 0 ? 1 : -1;
          this.nodes.push({ y });
          this.leaves.push(new Leaf(side, i, this.height, y, this.curvature, this.lean, this.xOffset, this.group));
          // Occasional extra leaf on alternating side.
          if (Math.random() > 0.55) {
            this.leaves.push(new Leaf(-side, i + 0.5, this.height, y, this.curvature, this.lean, this.xOffset, this.group));
          }
        }

        // Stem path element.
        this.stemEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        this.stemEl.setAttribute('fill', 'none');
        this.stemEl.setAttribute('stroke', 'url(#stemGrad)');
        this.stemEl.setAttribute('stroke-linecap', 'round');
        this.stemEl.setAttribute('stroke-width', '7');
        this.group.insertBefore(this.stemEl, this.group.firstChild);

        // Apex bud.
        this.budEl = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
        this.budEl.setAttribute('r', '5');
        this.budEl.setAttribute('fill', '#84cc16');
        this.group.appendChild(this.budEl);

        // Shift each plant horizontally so overlapping generations don't perfectly coincide.
        this.group.setAttribute('transform', `translate(${this.xOffset.toFixed(2)}, 0)`);
      }

      remove() {
        if (this.group && this.group.parentNode) {
          this.group.parentNode.removeChild(this.group);
        }
      }

      stemPath(progress, windPhase) {
        // Build a tapered curved stem from (0,0) up to current apex.
        const segs = 30;
        const points = [];
        const sway = Math.sin(windPhase * 0.7 + this.xOffset * 0.02) * 18 +
                     Math.sin(windPhase * 1.4) * 6;
        for (let i = 0; i <= segs; i++) {
          const t = i / segs;
          const y = -t * progress * this.height;
          const x =
            Math.sin(t * Math.PI * 0.9) * this.curvature * 40 +
            t * this.lean * this.height * 0.35 +
            t * t * sway; // sway increases toward the tip
          points.push([x, y]);
        }
        // Quadratic spline for smoothness.
        let d = `M ${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}`;
        for (let i = 1; i < points.length; i++) {
          const p0 = points[i - 1];
          const p1 = points[i];
          const mx = (p0[0] + p1[0]) / 2;
          const my = (p0[1] + p1[1]) / 2;
          d += ` Q ${p0[0].toFixed(2)} ${p0[1].toFixed(2)} ${mx.toFixed(2)} ${my.toFixed(2)}`;
        }
        const last = points[points.length - 1];
        d += ` L ${last[0].toFixed(2)} ${last[1].toFixed(2)}`;
        return { d, apex: last };
      }

      update(dt, windPhase, cycle) {
        const growProgress = clamp(cycle, 0, 1);
        const displayProgress = smoothstep(0, 1, growProgress);

        const stem = this.stemPath(displayProgress, windPhase);
        this.stemEl.setAttribute('d', stem.d);
        this.stemEl.setAttribute('stroke-width', lerp(9, 4, displayProgress).toFixed(2));

        // Move bud to apex.
        this.budEl.setAttribute('cx', stem.apex[0].toFixed(2));
        this.budEl.setAttribute('cy', stem.apex[1].toFixed(2));
        const budScale = lerp(0.3, 1, displayProgress);
        this.budEl.setAttribute('opacity', budScale.toFixed(3));

        // Update leaves.
        for (const leaf of this.leaves) {
          leaf.update(dt, displayProgress, windPhase);
          leaf.draw();
        }

        // Fade the whole plant out near the end of its life.
        const fadeStart = 0.75;
        const fadeDur = 0.25;
        let opacity = 1;
        if (growProgress > fadeStart) {
          opacity = 1 - (growProgress - fadeStart) / fadeDur;
        }
        this.group.setAttribute('opacity', clamp(opacity, 0, 1).toFixed(3));
      }
    }

    // -------------------------------------------------------------------------
    // Garden: overlapping plants for a seamless, ever-changing loop
    // -------------------------------------------------------------------------
    class Garden {
      constructor() {
        this.duration = 14;
        this.overlap = this.duration * 0.55;
        this.plants = [];
        this.nextSpawn = 0;
        this.time = 0;
      }

      spawn(startTime) {
        const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
        plantGroup.appendChild(g);
        const p = new Plant(g);
        p.startTime = startTime;
        this.plants.push(p);
      }

      update(dt, windPhase) {
        this.time += dt;

        // Spawn a new plant every overlap interval.
        while (this.time >= this.nextSpawn) {
          this.spawn(this.nextSpawn);
          this.nextSpawn += this.overlap;
        }

        // Update and fade out each plant based on its own age.
        for (const p of this.plants) {
          const cycle = (this.time - p.startTime) / this.duration;
          p.update(dt, windPhase, cycle);
        }

        // Remove fully faded plants and their DOM nodes.
        this.plants = this.plants.filter(p => {
          const cycle = (this.time - p.startTime) / this.duration;
          if (cycle >= 1) {
            p.remove();
            return false;
          }
          return true;
        });
      }
    }

    // -------------------------------------------------------------------------
    // Main loop
    // -------------------------------------------------------------------------
    const garden = new Garden();
    let windPhase = Math.random() * 100;
    let lastTime = performance.now();

    function frame(now) {
      const dt = Math.min((now - lastTime) / 1000, 0.05);
      lastTime = now;
      windPhase += dt;

      garden.update(dt, windPhase);
      requestAnimationFrame(frame);
    }
    requestAnimationFrame(frame);
  </script>
</body>
</html>