























<!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>
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。