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

推荐订阅源

V
Vulnerabilities – Threatpost
aimingoo的专栏
aimingoo的专栏
B
Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
GbyAI
GbyAI
阮一峰的网络日志
阮一峰的网络日志
Engineering at Meta
Engineering at Meta
IT之家
IT之家
V
Visual Studio Blog
The Cloudflare Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
A
About on SuperTechFans
博客园 - 聂微东
Blog — PlanetScale
Blog — PlanetScale
N
News and Events Feed by Topic
A
Arctic Wolf
WordPress大学
WordPress大学
小众软件
小众软件
C
CERT Recently Published Vulnerability Notes
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
D
Darknet – Hacking Tools, Hacker News & Cyber Security
F
Fortinet All Blogs
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Y
Y Combinator Blog
T
Threat Research - Cisco Blogs
Latest news
Latest news
Simon Willison's Weblog
Simon Willison's Weblog
Cyberwarzone
Cyberwarzone
S
Schneier on Security
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
L
Lohrmann on Cybersecurity
Stack Overflow Blog
Stack Overflow Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
P
Privacy International News Feed
J
Java Code Geeks
Spread Privacy
Spread Privacy
宝玉的分享
宝玉的分享
I
Intezer
L
LangChain Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
G
GRAHAM CLULEY
博客园 - 叶小钗
博客园 - 三生石上(FineUI控件)
The GitHub Blog
The GitHub Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
N
News and Events Feed by Topic
AWS News Blog
AWS News Blog
Attack and Defense Labs
Attack and Defense Labs
Security Archives - TechRepublic
Security Archives - TechRepublic
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO

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模式,速度快了,降智吗 天才程序员想要复活,还有可用的codex公益站么 里斯本丸沉没照进现代了 [富可敌国] [一叶知秋API]友仔们 我们换域名了~~ 记得更新一下哦 有点莫名其妙,被阿里云警告了 从道观回家之前,我和师兄问道 【picpi 皮皮公益站】为了防止有人拿去卖,邀请码发放规则更新。 美国 FAA: 我们需要你,游戏玩家,来当空管吧 vibe时用文言省tok吗? 有没有用? 会降表现吗? Codex CLI 官方这个 imagegen 的 Skill 到底是干啥的?哪有对应工具啊? 求问关于尼区和美区开通Claude 换设备登录telegram国内号码老账号 需要收费咋办? 发现hotmail的额度特别耐用 最近还有能正常用的claude中转站吗? 避雷闲鱼上面的CC中转站 现在cursor的优势是什么呢? OpenAI 回应马斯克要求罢免奥尔特曼:搞法律突袭,扰乱诉讼 谁在吹opencode go套餐啊,又慢量又少 【SamAltman】奥特曼被燃烧瓶袭击后的回应 咸鱼上359买的claude MAX 5x ,美国假家宽,看看能活几天 想问问跳蚤市场开的Pro和Plus 虚拟卡链接求助 [开源插件] 做了一个适合科研佬的GPT插件 【AI小说】拿AI跑了一部小说,佬们看看质量怎么样 总是能在首页看到opus4.6鞭尸推送 这个别名邮箱可以注册gpt 一个人在外地的话,佬们周末都做什么 你们ddg还能行不 获取不到新的邮箱 了····· claude code修复codex windows升级0.120.0 无法打开问题 我现在Zeabur上搭建了CPA服务,怎么再接入new api来做分发 杭州有么有佬友在搞AI应用这块的,四年前端转AI开发 汇丰、渣打两家银行获得香港稳定币牌照 【开源推广】 AIUsage:聚合多个 AI 平台配额与用量的 高颜值 macOS端 CPA看板 APP Newapi吃服务器内存多吗 中行跨境通疑限制无卡连续交易 或为应对盗刷 突然不能用表情回应话题了 codex是不是降额度了 反馈关于 “快问快答”标签的乱象 opencode版本1.4.3 无法上传图片问题 想问一下怎么解决这个问题,就是终端太多? codex更新到0.120.0之后无法加载以前的会话 sub2api怎么部署? 分享一个自用的南京继续教育平台视频自动播放下一集的油猴脚本 zotero9出来了 Claude正在向我推销付费项目,那能让你轻易得逞嘛 甲骨文用脚本开出来4个2+12咋办啊佬们,我还是免费号 各个厂的coding plan lite都绝版了? claude code 20美金账户问题 联通元景套餐续费问题 ai时代下的一些思考(诚邀大家讨论) 出境易GPT订阅pro求助 今年到目前股市的操作。 刚收到短信之前跑路的那家可以兑换了 佬们都用境外服务器做什么呢? 甲骨文4+24 求助领pro时候报错-付款页面出错。请重试。如果问题依然存在,请访问help.openai.com。 cloudflare 浏览器渲染增加了 CDP与mcp支持 SUB2API 导入 rt 时报错显示 Request failed with status code 502 如何解决 讨论一下怎么整理笔记 codex0.120.0更新后无法启动,回退 0.119.0正常使用 冰佬的公益站也不行了吗 三角洲直接给我封了10年 有佬友知道怎么起诉么 88VIP邀请 经过排查大概确定反重力代理报错问题了 【求助】openrouter 今年4月用国内visa卡充值后导致封禁,无法使用外国模型 奥特曼家被炸 自用,高信息量回复收集 求助sub2api分组问题 【新人报道】注册成功了 分享100个codex free账号 招聘 深圳客户端开发(flutter) 20k+
怎么 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>