文章也可以是一块可以操作的实验台。在下面的预览中,HTML 负责结构、CSS 负责样式,JavaScript 让波形随时间运动。它是正在运行的网页,可以直接操作。
波的叠加:动起来的 HTML#
拖动「相位差」,观察两列波的叠加:0° 时相互增强,180° 时相互抵消。还可以调整速度、暂停动画、重新运行,或全屏查看。三个波形使用相同的振幅标尺。
查看完整 HTML 源码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>波的叠加 · HTML 动效示例</title>
<style>
:root{color-scheme:light dark;--ink:#263b48;--muted:#75848d;--accent:#527e94;--border:#dfe5e9;--paper:#fcfcfd;--plot:#f8fafb;--button:#fff;--grid:#dfe5e9;--axis:#d7e0e5;--wave-a:#8ba8b9;--wave-b:#c29981;--on-accent:#fff}
@media(prefers-color-scheme:dark){:root{--ink:#e4eaf0;--muted:#94a3b0;--accent:#b4edfc;--border:#30343b;--paper:#101318;--plot:#151a21;--button:#1c232d;--grid:#2a343e;--axis:#465362;--wave-a:#7a9bb1;--wave-b:#d2a389;--on-accent:#152830}}
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.6 system-ui,"Microsoft YaHei",sans-serif}
main{max-width:1000px;margin:auto;padding:24px}header{display:flex;align-items:center;justify-content:space-between;gap:14px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted)}h1{font-size:25px;line-height:1.3;font-weight:600;margin:7px 0}header p{font-size:12px;color:var(--muted);margin:0}.badge{font-size:10px;color:var(--accent);white-space:nowrap;border:1px solid var(--border);padding:5px 9px;border-radius:20px}
.plot{margin:20px 0 12px;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--plot);position:relative}canvas{display:block;width:100%;height:245px}.legend{display:flex;gap:18px;flex-wrap:wrap;font-size:11px;color:var(--muted);margin-bottom:18px}.legend span:before{content:"";display:inline-block;width:14px;height:3px;background:var(--color);margin-right:6px;vertical-align:middle;border-radius:2px}
.controls{display:grid;grid-template-columns:1fr 1fr;gap:18px}label{font-size:12px;display:block}label>span{display:flex;justify-content:space-between;gap:8px}output{font-variant-numeric:tabular-nums;color:var(--accent)}input{width:100%;accent-color:var(--accent);margin:10px 0 0;cursor:pointer}.footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:18px}button{font:inherit;font-size:12px;border:1px solid var(--border);border-radius:7px;padding:7px 13px;background:var(--button);color:var(--ink);cursor:pointer}button.primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}button:hover{filter:brightness(.95)}button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.hint{font-size:10px;color:var(--muted)}
@media(max-width:500px){main{padding:18px}h1{font-size:22px}.badge{display:none}canvas{height:220px}.controls{gap:14px}.footer{align-items:flex-start}.hint{max-width:110px;text-align:right}}
</style>
</head>
<body>
<main>
<header><div><div class="eyebrow">PLAYGROUND / 001</div><h1>当两列波相遇</h1><p>拖动相位,观察波形如何相互增强或抵消。</p></div><span class="badge">HTML + CSS + JavaScript</span></header>
<div class="plot"><canvas id="wave" role="img" aria-label="两列正弦波及其叠加波的实时动画"></canvas></div>
<div class="legend"><span style="--color:var(--wave-a)">波 A</span><span style="--color:var(--wave-b)">波 B</span><span style="--color:var(--accent)">叠加结果 A + B</span></div>
<div class="controls">
<label for="phase"><span>相位差 <output id="phase-value" for="phase">90°</output></span><input id="phase" type="range" min="0" max="360" value="90" step="1"></label>
<label for="speed"><span>播放速度 <output id="speed-value" for="speed">1.0×</output></span><input id="speed" type="range" min="0.2" max="2" value="1" step="0.1"></label>
</div>
<div class="footer"><div><button class="primary" id="pause" type="button" aria-pressed="false">暂停动画</button> <button id="reset" type="button">重置</button></div><span class="hint">同相增强 · 反相抵消</span></div>
</main>
<script>
const canvas = document.querySelector('#wave');
const ctx = canvas.getContext('2d');
const phase = document.querySelector('#phase');
const speed = document.querySelector('#speed');
const pause = document.querySelector('#pause');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
let running = !reducedMotion.matches;
let time = 0, last = 0, width = 0, height = 0, animation = 0;
function updateLabels() {
document.querySelector('#phase-value').textContent = phase.value + '°';
document.querySelector('#speed-value').textContent = Number(speed.value).toFixed(1) + '×';
pause.textContent = running ? '暂停动画' : '播放动画';
pause.setAttribute('aria-pressed', String(!running));
}
function draw() {
const style = getComputedStyle(canvas);
const color = name => style.getPropertyValue(name).trim();
ctx.clearRect(0, 0, width, height);
const mid = height / 2, amplitude = height * .16;
ctx.fillStyle = color('--grid');
for (let x = 16; x < width; x += 24) for (let y = 14; y < height; y += 24) {
ctx.beginPath(); ctx.arc(x, y, .8, 0, Math.PI * 2); ctx.fill();
}
ctx.strokeStyle = color('--axis'); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(width, mid); ctx.stroke();
const offset = Number(phase.value) * Math.PI / 180;
const waves = [
{color:color('--wave-a'), line:1.5, sample:x => Math.sin(x)},
{color:color('--wave-b'), line:1.5, sample:x => Math.sin(x + offset)},
{color:color('--accent'), line:3, sample:x => Math.sin(x) + Math.sin(x + offset)}
];
for (const wave of waves) {
ctx.beginPath(); ctx.strokeStyle = wave.color; ctx.lineWidth = wave.line;
for (let x = 0; x <= width; x += 2) {
const angle = x / Math.max(1, width) * Math.PI * 5 - time;
const y = mid - wave.sample(angle) * amplitude;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
}
function resize() {
const rect = canvas.getBoundingClientRect();
width = rect.width; height = rect.height;
const ratio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio);
ctx.setTransform(ratio, 0, 0, ratio, 0, 0); draw();
}
function tick(now) {
animation = 0;
if (!running || document.hidden) return;
if (last) time += Math.min((now - last) / 1000, .05) * Number(speed.value) * 1.8;
last = now; draw(); animation = requestAnimationFrame(tick);
}
function schedule() {
cancelAnimationFrame(animation); animation = 0; last = 0;
if (running && !document.hidden) animation = requestAnimationFrame(tick);
}
pause.addEventListener('click', () => { running = !running; updateLabels(); schedule(); });
phase.addEventListener('input', () => { updateLabels(); draw(); });
speed.addEventListener('input', updateLabels);
document.querySelector('#reset').addEventListener('click', () => {
phase.value = '90'; speed.value = '1'; time = 0;
running = !reducedMotion.matches; updateLabels(); draw(); schedule();
});
document.addEventListener('visibilitychange', schedule);
colorScheme.addEventListener('change', draw);
reducedMotion.addEventListener('change', () => { running = !reducedMotion.matches; updateLabels(); schedule(); });
new ResizeObserver(resize).observe(canvas);
updateLabels(); resize(); schedule();
</script>
</body>
</html>
如何在自己的文章中插入 HTML#
把包含 HTML、CSS 和 JavaScript 的完整页面保存为 public/html/my-demo.html。将文章后缀设为 .mdx,在正文中导入预览组件并指定文件路径:
import HtmlPreview from '@/components/materials/HtmlPreview.astro'
<HtmlPreview
src='/html/my-demo.html'
title='我的交互可视化'
height={580}
/>mdx同一篇文章可以插入多个预览。height 设置预览高度;窄屏会自动适应文章宽度,HTML 页面内部也应使用响应式布局。
上面的「下载 HTML」可以获得完整示例。修改其中的样式、绘图逻辑和控件,再保存到 public/html/,即可作为新演示嵌入文章。完整代码也可以通过「查看完整 HTML 源码」展开阅读。修改文章或 HTML 文件后,按网站原有流程在本地构建并发布。
适合展示什么#
- CSS 动画、交互图表、粒子运动、数学曲线及算法过程。
- Canvas 或 SVG 可视化,以及通过滑块、按钮控制的实验。
- 已有的独立 HTML 页面;将样式和脚本放在页面内即可像本例一样运行。
预览在独立框架中运行,样式不会影响文章。脚本可用于动画和交互,框架与博客的登录信息、本地设置相隔离。需要读取文件的演示可以提供文件选择按钮;依赖 fetch 或 ES 模块的外部服务需要允许跨域访问。若希望整页浏览,可使用「独立打开」。