├── CNAME ├── README.md ├── assets ├── audio │ ├── introduction_script.txt │ └── js │ │ └── audio-player.js └── css │ ├── fontawesome.min.css │ └── tailwind.min.css ├── categories ├── electromagnetism.html ├── interdisciplinary.html ├── mechanics.html ├── optics-modern.html ├── search.js ├── search.js.bak └── waves.html ├── index.html ├── physics-project-progress.md ├── simulations ├── electromagnetism │ ├── velocity-selector.html │ └── velocity-selector.js ├── mechanics │ ├── force-composition.html │ ├── force-composition.js │ ├── momentum-conservation.html │ └── momentum-conservation │ │ ├── 1d-collision.html │ │ ├── 1d-collision.js │ │ ├── 2d-collision.html │ │ ├── explosion.html │ │ ├── explosion.js │ │ ├── human-boat.html │ │ └── human-boat.js ├── optics-modern │ ├── double-slit.html │ └── relativity │ │ ├── length-contraction.js │ │ ├── light-cone.js │ │ ├── lorentz-transform.js │ │ ├── relativity-main.js │ │ ├── relativity.html │ │ └── time-dilation.js └── waves │ ├── doppler.html │ ├── harmonic.html │ ├── spring.html │ ├── transverse │ ├── particle_motion.js │ ├── transverse_main.js │ ├── transverse_wave.html │ ├── wave_animation.js │ └── wave_shape.js │ ├── wave-sum.html │ └── wave2d.html └── versions └── version_history.txt /CNAME: -------------------------------------------------------------------------------- 1 | ph.lisa23.com -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 高中物理可视化教学平台 2 | 3 | https://lisa94destiny.github.io/physics-simulation/ 4 | 5 | 这是一个专为高中物理教学设计的交互式可视化模拟平台。通过直观的动画和交互控制,帮助学生更好地理解物理概念和原理。 6 | 7 | ## 作者信息 8 | 9 | - Lisa(stardust) 10 | - AI探索者,物理教育者,开源~ 11 | - 个人公众号:Lisa是个机器人 12 | - 欢迎各位老师来交流讨论提需求~ 13 | 14 | ## 项目特点 15 | 16 | - 📊 **交互式模拟**:学生可以通过调整参数,观察物理现象的变化 17 | - 🎯 **直观可视化**:将抽象的物理概念转化为直观的视觉效果 18 | - 📱 **响应式设计**:适配各种设备,从手机到电脑都有良好体验(推荐电脑学习) 19 | - 🧠 **探究式学习**:鼓励学生通过观察和实验发现物理规律 20 | - 🔍 **全站搜索功能**:快速查找感兴趣的物理实验,支持模糊搜索 21 | - 🌐 **初高中知识衔接**:为未来的初中物理网站提供接口,实现知识的无缝衔接 22 | 23 | ## 五大领域 24 | 25 | 本平台涵盖高中物理的五大核心领域: 26 | 27 | 1. **力学**:研究物体运动与力的相互关系,包括牛顿力学、动量守恒、能量守恒等 28 | 2. **电磁学**:探索电场、磁场及其相互作用,包括电路、电磁感应等 29 | 3. **振动与波动**:研究周期性运动和波的传播特性,包括简谐运动、机械波等 30 | 4. **光学与现代物理**:涵盖几何光学、波动光学和现代物理基础知识 31 | 5. **交叉领域与实验技能**:物理与其他学科的交叉应用,以及实验设计与数据分析等技能 32 | 33 | 各领域已添加详细分类页面,可访问对应页面查看更多内容: 34 | - 力学:/categories/mechanics.html 35 | - 电磁学:/categories/electromagnetism.html 36 | - 振动与波动:/categories/waves.html 37 | - 光学与现代物理:/categories/optics-modern.html 38 | - 交叉领域与实验技能:/categories/interdisciplinary.html 39 | 40 | ## 热门模拟实验 41 | 42 | 1. **动量守恒模型**: 43 | - 一维碰撞:探索弹性、非弹性和完全非弹性碰撞中的动量守恒 44 | - 二维碰撞:研究平面上的碰撞现象(开发中) 45 | 2. **电磁感应实验**:探索移动导体棒在磁场中产生感应电动势的现象和规律(开发中) 46 | 3. **波的二维干涉**:观察两个波源在二维平面上产生的干涉图样 47 | 4. **双缝干涉**:探索光的波动性,通过双缝干涉现象测量光的波长 48 | 5. **交叉领域与实验技能**:学习物理实验数据的统计分析、误差处理和图形可视化技巧(开发中) 49 | 五大领域,更多模块开发中... 50 | 51 | ## 站点功能 52 | 53 | 1. **全站搜索**:在任意页面点击导航栏的搜索图标,快速查找实验 54 | - 支持模糊搜索和标签搜索 55 | - 提供热门推荐实验 56 | - 搜索结果高亮显示匹配文本 57 | 2. **直观导航**:通过分类卡片和导航菜单快速访问不同领域 58 | 3. **实验过滤**:在子页面中按难度和分类筛选实验 59 | 4. **移动端适配**:响应式设计,支持各种设备访问 60 | 61 | ## 社交媒体 62 | 63 | - GitHub: https://github.com/Lisa94destiny 64 | - 微信公众号: Lisa是个机器人(关注开发最新动态) 65 | - REDnote: 66 | - Twitter/X: @AIstardustX 67 | 68 | ## 使用方法 69 | 70 | 方法一:打开链接直接使用 https://lisa94destiny.github.io/physics-simulation/ 71 | 72 | 方法二: 73 | 1. 克隆或下载本仓库 74 | 2. 打开 `index.html` 文件即可开始使用 75 | 3. 点击任意模拟实验卡片,进入对应的模拟页面 76 | 4. 根据页面指引,调整参数,观察物理现象变化 77 | 5. 使用搜索功能快速查找需要的实验 78 | 79 | ## 项目结构 80 | 81 | ``` 82 | 物理可视化教学/ 83 | ├── index.html # 主页面 84 | ├── README.md # 项目说明文档 85 | ├── 技术栈说明.md # 技术栈详细说明 86 | ├── assets/ # 静态资源文件夹 87 | │ ├── css/ # 样式文件 88 | │ ├── js/ # 通用JavaScript文件 89 | │ └── images/ # 图片资源 90 | ├── categories/ # 物理学五大领域分类页面 91 | │ ├── mechanics.html # 力学 92 | │ ├── electromagnetism.html # 电磁学 93 | │ ├── waves.html # 振动与波动 94 | │ ├── optics-modern.html # 光学与现代物理 95 | │ ├── interdisciplinary.html # 交叉领域与实验技能 96 | │ └── search.js # 全站搜索组件 97 | ├── simulations/ # 模拟实验页面 98 | │ ├── mechanics/ # 力学实验 99 | │ │ └── momentum-conservation.html # 动量守恒 100 | │ ├── electromagnetism/ # 电磁学实验 101 | │ │ ├── electric-field.html # 电场可视化 102 | │ │ └── magnetic-field.html # 磁场与电流 103 | │ ├── waves/ # 振动与波动实验 104 | │ │ ├── transverse/ # 横波理解 105 | │ │ ├── harmonic.html # 简谐运动 106 | │ │ ├── wave2d.html # 波的二维干涉 107 | │ │ ├── spring.html # 弹簧振子 108 | │ │ ├── doppler.html # 多普勒效应 109 | │ │ └── wave-sum.html # 机械波叠加 110 | │ ├── optics-modern/ # 光学与现代物理实验 111 | │ │ ├── double-slit.html # 双缝干涉 112 | │ │ └── relativity/ # 相对论 113 | │ │ └── relativity.html # 狭义相对论 114 | │ └── interdisciplinary/ # 交叉领域实验 115 | │ └── data-analysis.html # 数据分析 116 | ├── logs/ # 错误和运行日志 117 | └── versions/ # 版本历史记录 118 | ``` 119 | 120 | ## 版本历史 121 | 122 | 当前版本:1.0.0 (2025年3月) 123 | - 添加全站搜索功能 124 | - 优化用户界面和导航体验 125 | - 统一各页面风格和交互模式 126 | - 增加与初中物理的知识衔接接口 127 | 128 | ## 许可证 129 | 130 | © 2025 物理可视化教学平台. 保留所有权利. -------------------------------------------------------------------------------- /assets/audio/introduction_script.txt: -------------------------------------------------------------------------------- 1 | 你好,欢迎来到高中物理可视化学习网站。 2 | 3 | 我是Lisa,网站开发者,那就让我为你介绍一下网站该怎么使用吧~ 4 | 5 | 这个平台旨在帮助你通过互动实验和可视化模拟来理解物理概念。不管你是正在为考试复习,还是只是对物理现象感到好奇,这里都能找到适合你的内容。 6 | 7 | 网站包含五个核心领域:力学、电磁学、振动与波、光学与现代物理,以及跨学科技能和实验技术。每个领域都有多个互动实验,让你可以动手操作并观察结果。 8 | 9 | 比如说,你可以在力学部分调整参数,观察动量守恒的碰撞模型、人船模型; 10 | 11 | 或者在波动部分,你可以观察横波是怎么产生的,并了解不同波的干涉模式。 12 | 13 | 通过调整各种参数,你可以非常直观地看到物理定律是如何工作的。 14 | 15 | 网站设计也非常直观。 16 | 17 | 顶部导航栏可以帮助你快速找到感兴趣的领域,而搜索功能则可以让你直接跳转到特定的实验。每个实验页面都有清晰的说明和控制面板,让你轻松上手。 18 | 19 | 最重要的是,所有这些都是免费的!我会完全开放给所有想学习物理的同学。 20 | 21 | 我相信,通过这种互动方式学习物理,一定能让这门看似抽象的学科变得更加生动和有趣。 22 | 23 | 希望你能在这个平台上发现物理的奇妙之处。 24 | 25 | 如果有任何问题或建议,别忘了联系我。可以搜索公众号:lisa是个机器人,关注我的开发动态。 26 | 27 | 好啦,快去愉快地学习吧~ 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /assets/audio/js/audio-player.js: -------------------------------------------------------------------------------- 1 | // 音频播放器组件 2 | class AudioPlayer { 3 | constructor() { 4 | this.audio = null; 5 | this.isPlaying = false; 6 | this.playerElement = null; 7 | this.progressBar = null; 8 | this.volumeControl = null; 9 | this.speedControl = null; 10 | this.audioUrl = 'https://minimax-algeng-chat-tts.oss-cn-wulanchabu.aliyuncs.com/audio%2Ftts-mp3-20250421024917-McomgbDL.mp3?Expires=86401745174957&OSSAccessKeyId=LTAI5tGLnRTkBjLuYPjNcKQ8&Signature=66%2F93uIyxt9MWwSCfQfmJ4jdbwg%3D'; 11 | // 本地音频备份文件 12 | this.fallbackAudioUrl = '../introduction.mp3'; 13 | } 14 | 15 | // 初始化播放器 16 | init() { 17 | // 创建音频元素 18 | this.audio = new Audio(); 19 | 20 | // 创建底部播放器容器 21 | this.createPlayerElement(); 22 | 23 | // 绑定事件 24 | this.bindEvents(); 25 | 26 | // 加载音频 27 | this.loadAudio(); 28 | } 29 | 30 | // 加载音频文件 31 | loadAudio() { 32 | // 首先尝试远程URL 33 | this.audio.src = this.audioUrl; 34 | 35 | // 添加错误处理,如果远程加载失败则使用本地备份 36 | this.audio.onerror = () => { 37 | console.log('远程音频加载失败,使用本地备份'); 38 | this.audio.src = this.fallbackAudioUrl; 39 | }; 40 | 41 | // 预加载音频 42 | this.audio.load(); 43 | } 44 | 45 | // 创建播放器元素 46 | createPlayerElement() { 47 | // 创建播放器容器 48 | this.playerElement = document.createElement('div'); 49 | this.playerElement.className = 'fixed bottom-0 left-0 right-0 bg-indigo-100 shadow-lg transform translate-y-full transition-transform duration-300 z-50'; 50 | this.playerElement.style.minHeight = '64px'; 51 | 52 | // 播放器内容 53 | const playerContent = ` 54 |
55 |
56 | 57 |
58 |
59 | 63 | 66 |
67 |
68 | 0:00 69 | / 70 | 0:00 71 |
72 |
73 | 74 | 75 |
76 |
77 |
78 |
79 |
80 | 81 | 82 |
83 |
84 | 87 | 89 |
90 |
91 | 94 | 104 |
105 | 108 |
109 |
110 |
111 | `; 112 | 113 | this.playerElement.innerHTML = playerContent; 114 | document.body.appendChild(this.playerElement); 115 | 116 | // 获取播放器中的元素 117 | this.playPauseBtn = document.getElementById('play-pause-btn'); 118 | this.playIcon = document.getElementById('play-icon'); 119 | this.pauseIcon = document.getElementById('pause-icon'); 120 | this.restartBtn = document.getElementById('restart-btn'); 121 | this.progressBar = document.getElementById('progress-bar'); 122 | this.currentTimeDisplay = document.getElementById('current-time'); 123 | this.durationDisplay = document.getElementById('duration'); 124 | this.volumeControl = document.getElementById('volume-control'); 125 | this.speedControl = document.getElementById('speed-control'); 126 | this.speedBtn = document.getElementById('speed-btn'); 127 | this.speedDropdown = document.getElementById('speed-dropdown'); 128 | this.closePlayerBtn = document.getElementById('close-player-btn'); 129 | } 130 | 131 | // 绑定事件 132 | bindEvents() { 133 | // 播放/暂停按钮 134 | this.playPauseBtn.addEventListener('click', () => { 135 | this.togglePlayPause(); 136 | }); 137 | 138 | // 重新开始按钮 139 | this.restartBtn.addEventListener('click', () => { 140 | this.restart(); 141 | }); 142 | 143 | // 音量控制 144 | this.volumeControl.addEventListener('input', () => { 145 | this.setVolume(this.volumeControl.value); 146 | }); 147 | 148 | // 速度控制 149 | this.speedBtn.addEventListener('click', () => { 150 | // 切换下拉菜单显示 151 | this.speedDropdown.classList.toggle('hidden'); 152 | }); 153 | 154 | // 速度选项点击事件 155 | const speedOptions = document.querySelectorAll('.speed-option'); 156 | speedOptions.forEach(option => { 157 | option.addEventListener('click', () => { 158 | const speed = option.getAttribute('data-speed'); 159 | this.setPlaybackRate(speed); 160 | this.speedBtn.querySelector('span').textContent = `${speed}x`; 161 | this.speedDropdown.classList.add('hidden'); 162 | 163 | // 更新选中状态 164 | speedOptions.forEach(opt => { 165 | if (opt.getAttribute('data-speed') === speed) { 166 | opt.classList.add('bg-indigo-500', 'text-white'); 167 | } else { 168 | opt.classList.remove('bg-indigo-500', 'text-white'); 169 | } 170 | }); 171 | }); 172 | }); 173 | 174 | // 关闭按钮 175 | this.closePlayerBtn.addEventListener('click', () => { 176 | this.hidePlayer(); 177 | }); 178 | 179 | // 点击播放器外部时隐藏速度下拉菜单 180 | document.addEventListener('click', (e) => { 181 | if (e.target !== this.speedBtn && !this.speedBtn.contains(e.target) && 182 | !this.speedDropdown.contains(e.target)) { 183 | this.speedDropdown.classList.add('hidden'); 184 | } 185 | }); 186 | 187 | // 音频事件 188 | this.audio.addEventListener('timeupdate', () => { 189 | this.updateProgress(); 190 | }); 191 | 192 | this.audio.addEventListener('ended', () => { 193 | this.playIcon.classList.remove('hidden'); 194 | this.pauseIcon.classList.add('hidden'); 195 | this.isPlaying = false; 196 | }); 197 | 198 | this.audio.addEventListener('loadedmetadata', () => { 199 | this.updateDuration(); 200 | }); 201 | 202 | // 进度条点击事件 - 允许用户通过点击进度条跳转 203 | const progressContainer = this.progressBar.parentElement; 204 | progressContainer.addEventListener('click', (e) => { 205 | const rect = progressContainer.getBoundingClientRect(); 206 | const pos = (e.clientX - rect.left) / rect.width; 207 | this.audio.currentTime = pos * this.audio.duration; 208 | }); 209 | } 210 | 211 | // 显示播放器 212 | showPlayer() { 213 | this.playerElement.classList.remove('translate-y-full'); 214 | } 215 | 216 | // 隐藏播放器 217 | hidePlayer() { 218 | this.playerElement.classList.add('translate-y-full'); 219 | this.pause(); 220 | } 221 | 222 | // 播放 223 | play() { 224 | const playPromise = this.audio.play(); 225 | 226 | if (playPromise !== undefined) { 227 | playPromise.then(() => { 228 | // 播放成功 229 | this.playIcon.classList.add('hidden'); 230 | this.pauseIcon.classList.remove('hidden'); 231 | this.isPlaying = true; 232 | }).catch(error => { 233 | // 播放失败,可能是浏览器策略限制 234 | console.error('播放失败:', error); 235 | // 提示用户交互 236 | alert('请点击播放按钮开始播放音频'); 237 | }); 238 | } 239 | } 240 | 241 | // 暂停 242 | pause() { 243 | this.audio.pause(); 244 | this.playIcon.classList.remove('hidden'); 245 | this.pauseIcon.classList.add('hidden'); 246 | this.isPlaying = false; 247 | } 248 | 249 | // 切换播放/暂停 250 | togglePlayPause() { 251 | if (this.isPlaying) { 252 | this.pause(); 253 | } else { 254 | this.play(); 255 | } 256 | } 257 | 258 | // 重新开始 259 | restart() { 260 | this.audio.currentTime = 0; 261 | this.play(); 262 | } 263 | 264 | // 设置音量 265 | setVolume(value) { 266 | this.audio.volume = value; 267 | } 268 | 269 | // 设置播放速度 270 | setPlaybackRate(value) { 271 | this.audio.playbackRate = value; 272 | } 273 | 274 | // 更新进度条 275 | updateProgress() { 276 | const progress = (this.audio.currentTime / this.audio.duration) * 100; 277 | this.progressBar.style.width = `${progress}%`; 278 | 279 | // 更新当前时间显示 280 | this.currentTimeDisplay.textContent = this.formatTime(this.audio.currentTime); 281 | } 282 | 283 | // 更新总时长显示 284 | updateDuration() { 285 | this.durationDisplay.textContent = this.formatTime(this.audio.duration); 286 | } 287 | 288 | // 格式化时间为 mm:ss 289 | formatTime(seconds) { 290 | const mins = Math.floor(seconds / 60); 291 | const secs = Math.floor(seconds % 60); 292 | return `${mins}:${secs < 10 ? '0' : ''}${secs}`; 293 | } 294 | 295 | // 下载音频并保存到本地 296 | downloadAudio() { 297 | // 使用XMLHttpRequest下载音频 298 | const xhr = new XMLHttpRequest(); 299 | xhr.open('GET', this.audioUrl, true); 300 | xhr.responseType = 'blob'; 301 | 302 | xhr.onload = () => { 303 | if (xhr.status === 200) { 304 | // 下载成功,保存到本地 305 | const blob = xhr.response; 306 | 307 | // 创建下载链接 308 | const a = document.createElement('a'); 309 | const url = URL.createObjectURL(blob); 310 | a.href = url; 311 | a.download = 'introduction.mp3'; 312 | document.body.appendChild(a); 313 | a.click(); 314 | 315 | // 清理 316 | setTimeout(() => { 317 | document.body.removeChild(a); 318 | window.URL.revokeObjectURL(url); 319 | }, 100); 320 | } else { 321 | console.error('音频下载失败'); 322 | } 323 | }; 324 | 325 | xhr.onerror = () => { 326 | console.error('音频下载请求失败'); 327 | }; 328 | 329 | xhr.send(); 330 | } 331 | } 332 | 333 | // 在页面加载完成后添加音频按钮 334 | document.addEventListener('DOMContentLoaded', () => { 335 | // 创建音频播放按钮 336 | const audioButton = document.createElement('a'); 337 | audioButton.id = 'audio-guide-btn'; 338 | audioButton.href = "#"; 339 | audioButton.className = 'inline-block bg-white text-indigo-600 font-medium px-6 py-3 rounded-lg shadow-md hover:bg-gray-50 transition duration-300 btn ml-4'; 340 | audioButton.innerHTML = '上手导引'; 341 | 342 | // 将按钮添加到页面 343 | const exploreButton = document.querySelector('a[href="#simulations"]'); 344 | if (exploreButton && exploreButton.parentNode) { 345 | exploreButton.parentNode.appendChild(audioButton); 346 | } 347 | 348 | // 初始化音频播放器 349 | const audioPlayer = new AudioPlayer(); 350 | audioPlayer.init(); 351 | 352 | // 为移动端添加音频按钮 353 | const mobileMenu = document.getElementById('mobile-menu'); 354 | if (mobileMenu) { 355 | const mobileMenuContent = mobileMenu.querySelector('.px-2.pt-2.pb-3.space-y-1'); 356 | if (mobileMenuContent) { 357 | const mobileAudioButton = document.createElement('a'); 358 | mobileAudioButton.href = "#"; 359 | mobileAudioButton.className = 'block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-indigo-600 hover:bg-gray-50'; 360 | mobileAudioButton.innerHTML = '上手导引'; 361 | 362 | mobileMenuContent.appendChild(mobileAudioButton); 363 | 364 | // 点击事件 365 | mobileAudioButton.addEventListener('click', (e) => { 366 | e.preventDefault(); 367 | // 关闭移动端菜单 368 | mobileMenu.classList.add('hidden'); 369 | // 显示音频播放器并开始播放 370 | audioPlayer.showPlayer(); 371 | audioPlayer.play(); 372 | }); 373 | } 374 | } 375 | 376 | // 点击按钮时显示播放器并开始播放 377 | audioButton.addEventListener('click', (e) => { 378 | e.preventDefault(); 379 | audioPlayer.showPlayer(); 380 | audioPlayer.play(); 381 | }); 382 | }); -------------------------------------------------------------------------------- /assets/css/fontawesome.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | .fa,.fas,.far,.fal,.fad,.fab{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1} 6 | .fa-arrow-left:before{content:"\f060"} 7 | .fa-rocket:before{content:"\f135"} 8 | /* 基本的Font Awesome图标样式 */ 9 | .fas{font-family:"Font Awesome 6 Free";font-weight:900} 10 | .fab{font-family:"Font Awesome 6 Brands";font-weight:400} 11 | /* 更多图标样式将在需要时添加 */ -------------------------------------------------------------------------------- /assets/css/tailwind.min.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v3.3.0 | MIT License | https://tailwindcss.com */ 2 | *,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb} 3 | ::after,::before{--tw-content:''} 4 | html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal} 5 | body{margin:0;line-height:inherit} 6 | /* 基本的Tailwind样式 */ 7 | .container{width:100%;margin-right:auto;margin-left:auto;padding-right:1rem;padding-left:1rem} 8 | .flex{display:flex} 9 | .items-center{align-items:center} 10 | .justify-center{justify-content:center} 11 | .bg-white{background-color:#fff} 12 | .text-gray-800{color:#1f2937} 13 | .font-semibold{font-weight:600} 14 | .text-xl{font-size:1.25rem;line-height:1.75rem} 15 | .p-4{padding:1rem} 16 | .m-4{margin:1rem} 17 | .rounded{border-radius:0.25rem} 18 | /* 更多基础样式将在需要时添加 */ -------------------------------------------------------------------------------- /categories/search.js: -------------------------------------------------------------------------------- 1 | // 所有实验数据 2 | const allExperiments = [ 3 | { 4 | title: "双缝干涉", 5 | category: "光学与现代物理", 6 | url: "simulations/optics-modern/double-slit.html", 7 | tags: ["光的干涉", "衍射", "波动光学", "杨氏实验", "双缝"] 8 | }, 9 | { 10 | title: "简谐运动", 11 | category: "振动与波动", 12 | url: "simulations/waves/harmonic.html", 13 | tags: ["简谐", "振动", "周期", "频率", "弹簧振子", "单摆"] 14 | }, 15 | { 16 | title: "波的二维干涉", 17 | category: "振动与波动", 18 | url: "simulations/waves/wave2d.html", 19 | tags: ["波动", "干涉", "二维", "波面", "水波"] 20 | }, 21 | { 22 | title: "弹簧振子", 23 | category: "振动与波动", 24 | url: "simulations/waves/spring.html", 25 | tags: ["弹簧", "振动", "胡克定律", "简谐运动"] 26 | }, 27 | { 28 | title: "多普勒效应", 29 | category: "振动与波动", 30 | url: "simulations/waves/doppler.html", 31 | tags: ["多普勒", "频率移动", "声波", "波源运动"] 32 | }, 33 | { 34 | title: "机械波叠加", 35 | category: "振动与波动", 36 | url: "simulations/waves/wave-sum.html", 37 | tags: ["波叠加", "干涉", "驻波", "行波"] 38 | }, 39 | { 40 | title: "横波理解", 41 | category: "振动与波动", 42 | url: "simulations/waves/transverse.html", 43 | tags: ["横波", "波动", "振动方向", "传播方向", "波形"] 44 | }, 45 | { 46 | title: "狭义相对论", 47 | category: "光学与现代物理", 48 | url: "simulations/optics-modern/relativity/relativity.html", 49 | tags: ["爱因斯坦", "相对论", "时间膨胀", "长度收缩", "相对性"] 50 | }, 51 | { 52 | title: "动量守恒", 53 | category: "力学", 54 | url: "simulations/mechanics/momentum-conservation.html", 55 | tags: ["动量", "碰撞", "守恒", "弹性碰撞", "非弹性碰撞"] 56 | }, 57 | { 58 | title: "力的合成与分解", 59 | category: "力学", 60 | url: "simulations/mechanics/force-composition.html", 61 | tags: ["力", "合成", "分解", "平行四边形法则", "三角形法则", "分力", "合力"] 62 | }, 63 | { 64 | title: "电场可视化", 65 | category: "电磁学", 66 | url: "simulations/electromagnetism/electric-field.html", 67 | tags: ["电场", "静电场", "场强", "电力线", "点电荷"] 68 | }, 69 | { 70 | title: "磁场与电流", 71 | category: "电磁学", 72 | url: "simulations/electromagnetism/magnetic-field.html", 73 | tags: ["安培力", "磁场", "洛伦兹力", "电流", "右手定则"] 74 | }, 75 | { 76 | title: "速度选择器", 77 | category: "电磁学", 78 | url: "simulations/electromagnetism/velocity-selector.html", 79 | tags: ["电场", "磁场", "洛伦兹力", "电磁力", "带电粒子"] 80 | }, 81 | { 82 | title: "数据分析实验", 83 | category: "交叉领域与实验技能", 84 | url: "simulations/interdisciplinary/data-analysis.html", 85 | tags: ["数据", "分析", "误差", "统计", "拟合"] 86 | } 87 | ]; 88 | 89 | // 热门推荐实验 90 | const popularExperiments = [ 91 | { 92 | title: "双缝干涉", 93 | category: "光学与现代物理", 94 | url: "simulations/optics-modern/double-slit.html" 95 | }, 96 | { 97 | title: "力的合成与分解", 98 | category: "力学", 99 | url: "simulations/mechanics/force-composition.html" 100 | }, 101 | { 102 | title: "简谐运动", 103 | category: "振动与波动", 104 | url: "simulations/waves/harmonic.html" 105 | }, 106 | { 107 | title: "电场可视化", 108 | category: "电磁学", 109 | url: "simulations/electromagnetism/electric-field.html" 110 | } 111 | ]; 112 | 113 | // 创建搜索组件元素 114 | function createSearchComponent() { 115 | // 创建搜索图标 116 | const searchIcon = document.createElement('div'); 117 | searchIcon.className = 'search-icon cursor-pointer flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary hover:bg-gray-50'; 118 | searchIcon.innerHTML = ''; 119 | 120 | // 创建搜索模态框 121 | const searchModal = document.createElement('div'); 122 | searchModal.className = 'search-modal fixed inset-0 bg-gray-600 bg-opacity-50 flex items-start justify-center pt-10 md:pt-20 z-50 hidden'; 123 | 124 | // 搜索框容器 125 | const searchContainer = document.createElement('div'); 126 | searchContainer.className = 'search-container bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 overflow-hidden'; 127 | 128 | // 搜索框头部 129 | const searchHeader = document.createElement('div'); 130 | searchHeader.className = 'search-header flex items-center p-4 border-b'; 131 | searchHeader.innerHTML = ` 132 | 133 | 134 | 137 | `; 138 | 139 | // 搜索结果区域 140 | const searchResults = document.createElement('div'); 141 | searchResults.className = 'search-results max-h-60 md:max-h-80 overflow-y-auto p-4'; 142 | 143 | // 初始状态:热门推荐 144 | const popularSection = document.createElement('div'); 145 | popularSection.className = 'popular-section'; 146 | popularSection.innerHTML = ` 147 |

热门推荐

148 | 159 | `; 160 | 161 | searchResults.appendChild(popularSection); 162 | 163 | // 搜索结果区域 164 | const resultsSection = document.createElement('div'); 165 | resultsSection.className = 'results-section hidden'; 166 | resultsSection.innerHTML = '

搜索结果

'; 167 | searchResults.appendChild(resultsSection); 168 | 169 | // 组装搜索模态框 170 | searchContainer.appendChild(searchHeader); 171 | searchContainer.appendChild(searchResults); 172 | searchModal.appendChild(searchContainer); 173 | 174 | // 添加到文档中 175 | document.body.appendChild(searchModal); 176 | 177 | // 添加响应式样式 178 | const styleEl = document.createElement('style'); 179 | styleEl.textContent = ` 180 | @media (max-width: 640px) { 181 | .search-modal { 182 | padding-top: 1rem; 183 | } 184 | .search-container { 185 | max-width: 95%; 186 | margin: 0 2.5%; 187 | } 188 | .search-results { 189 | max-height: 60vh; 190 | } 191 | } 192 | `; 193 | document.head.appendChild(styleEl); 194 | 195 | // 搜索逻辑 196 | const searchInputEl = searchModal.querySelector('.search-input'); 197 | const resultsItemsEl = searchModal.querySelector('.results-items'); 198 | const popularSectionEl = searchModal.querySelector('.popular-section'); 199 | const resultsSectionEl = searchModal.querySelector('.results-section'); 200 | 201 | // 添加事件监听 202 | searchIcon.addEventListener('click', () => { 203 | searchModal.classList.remove('hidden'); 204 | setTimeout(() => searchInputEl.focus(), 100); 205 | }); 206 | 207 | searchModal.querySelector('.search-close').addEventListener('click', () => { 208 | searchModal.classList.add('hidden'); 209 | }); 210 | 211 | // 点击模态框背景关闭 212 | searchModal.addEventListener('click', (e) => { 213 | if (e.target === searchModal) { 214 | searchModal.classList.add('hidden'); 215 | } 216 | }); 217 | 218 | // 搜索输入框事件 219 | searchInputEl.addEventListener('input', (e) => { 220 | const query = e.target.value.trim().toLowerCase(); 221 | 222 | if (query === '') { 223 | // 显示热门推荐 224 | popularSectionEl.classList.remove('hidden'); 225 | resultsSectionEl.classList.add('hidden'); 226 | return; 227 | } 228 | 229 | // 隐藏热门推荐,显示搜索结果 230 | popularSectionEl.classList.add('hidden'); 231 | resultsSectionEl.classList.remove('hidden'); 232 | 233 | // 模糊搜索逻辑 234 | const results = allExperiments.filter(exp => { 235 | // 搜索标题 236 | if (exp.title.toLowerCase().includes(query)) return true; 237 | 238 | // 搜索分类 239 | if (exp.category.toLowerCase().includes(query)) return true; 240 | 241 | // 搜索标签 242 | return exp.tags.some(tag => tag.toLowerCase().includes(query)); 243 | }); 244 | 245 | // 更新搜索结果 246 | if (results.length === 0) { 247 | resultsItemsEl.innerHTML = '
没有找到匹配的实验
'; 248 | } else { 249 | resultsItemsEl.innerHTML = results.map(exp => ` 250 | 251 |
252 |
253 |
${highlightMatch(exp.title, query)}
254 |
${exp.category}
255 |
256 |
257 | `).join(''); 258 | 259 | // 添加点击事件监听器 260 | resultsItemsEl.querySelectorAll('.experiment-item').forEach(item => { 261 | item.addEventListener('click', function(e) { 262 | // 先关闭搜索框,避免跳转问题 263 | searchModal.classList.add('hidden'); 264 | 265 | // 如果是移动设备,先延迟一下再跳转,确保模态框已关闭 266 | if (window.innerWidth < 768) { 267 | e.preventDefault(); 268 | const href = this.getAttribute('href'); 269 | setTimeout(() => { 270 | window.location.href = href; 271 | }, 100); 272 | } 273 | }); 274 | }); 275 | } 276 | }); 277 | 278 | // 键盘ESC关闭搜索 279 | document.addEventListener('keydown', (e) => { 280 | if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) { 281 | searchModal.classList.add('hidden'); 282 | } 283 | }); 284 | 285 | return searchIcon; 286 | } 287 | 288 | // 高亮匹配的文字 289 | function highlightMatch(text, query) { 290 | if (!query) return text; 291 | 292 | const lowerText = text.toLowerCase(); 293 | const lowerQuery = query.toLowerCase(); 294 | 295 | if (!lowerText.includes(lowerQuery)) return text; 296 | 297 | const index = lowerText.indexOf(lowerQuery); 298 | const before = text.substring(0, index); 299 | const match = text.substring(index, index + query.length); 300 | const after = text.substring(index + query.length); 301 | 302 | return `${before}${match}${after}`; 303 | } 304 | 305 | // 初始化搜索组件 306 | function initSearchComponent() { 307 | // 根据当前页面使用相应的主题颜色 308 | updateSearchThemeColor(); 309 | 310 | // 获取导航栏,更通用的选择器以同时支持主页和子页面 311 | const navbar = document.querySelector('nav'); 312 | if (navbar) { 313 | // 尝试寻找子页面的右侧导航区域 314 | let navbarRightSection = navbar.querySelector('.flex.items-center.space-x-4'); 315 | 316 | // 如果找不到子页面的格式,尝试寻找主页面的导航区域 317 | if (!navbarRightSection) { 318 | navbarRightSection = navbar.querySelector('.hidden.md\\:flex.items-center.space-x-4'); 319 | } 320 | 321 | // 如果还是找不到,就尝试创建一个新的导航区域 322 | if (!navbarRightSection) { 323 | // 尝试找到导航栏的容器 324 | const navContainer = navbar.querySelector('.flex.justify-between'); 325 | if (navContainer) { 326 | navbarRightSection = document.createElement('div'); 327 | navbarRightSection.className = 'flex items-center space-x-4'; 328 | navContainer.appendChild(navbarRightSection); 329 | } 330 | } 331 | 332 | if (navbarRightSection) { 333 | const searchIcon = createSearchComponent(); 334 | navbarRightSection.insertBefore(searchIcon, navbarRightSection.firstChild); 335 | } else { 336 | console.warn('找不到合适的导航区域来放置搜索图标'); 337 | } 338 | 339 | // 添加到移动端菜单 340 | const mobileMenu = document.getElementById('mobile-menu'); 341 | if (mobileMenu) { 342 | const mobileMenuContent = mobileMenu.querySelector('.px-2.pt-2.pb-3.space-y-1'); 343 | if (mobileMenuContent) { 344 | const mobileSearchIcon = document.createElement('a'); 345 | mobileSearchIcon.href = "#"; 346 | mobileSearchIcon.className = 'block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-indigo-600 hover:bg-gray-50'; 347 | mobileSearchIcon.innerHTML = '搜索'; 348 | 349 | // 点击事件 350 | mobileSearchIcon.addEventListener('click', (e) => { 351 | e.preventDefault(); 352 | // 关闭移动端菜单 353 | mobileMenu.classList.add('hidden'); 354 | // 打开搜索框 355 | document.querySelector('.search-modal').classList.remove('hidden'); 356 | // 聚焦搜索输入框 357 | setTimeout(() => document.querySelector('.search-input').focus(), 100); 358 | }); 359 | 360 | mobileMenuContent.appendChild(mobileSearchIcon); 361 | } 362 | } 363 | } 364 | } 365 | 366 | // 更新搜索组件的主题颜色以匹配当前页面 367 | function updateSearchThemeColor() { 368 | // 使用统一的蓝色,不根据当前页面变化 369 | let primaryColor = '#5E6AD2'; // 统一使用蓝色 370 | 371 | // 更新CSS变量 372 | document.documentElement.style.setProperty('--search-primary', primaryColor); 373 | 374 | // 动态添加样式 375 | const styleEl = document.createElement('style'); 376 | styleEl.textContent = ` 377 | .search-icon:hover { color: var(--search-primary) !important; } 378 | .search-results .icon { color: var(--search-primary) !important; } 379 | .search-input:focus { border-color: var(--search-primary) !important; } 380 | `; 381 | document.head.appendChild(styleEl); 382 | } 383 | 384 | // 在DOM加载完成后初始化搜索组件 385 | document.addEventListener('DOMContentLoaded', initSearchComponent); 386 | 387 | // 适配不同页面的路径问题 388 | function getCorrectPath(url) { 389 | // 检查当前URL是否是file://协议打开的本地文件 390 | const isFileProtocol = window.location.protocol === 'file:'; 391 | 392 | // 获取当前页面的路径 393 | const currentPath = window.location.pathname; 394 | 395 | // 标准化URL(去除可能的前导斜杠) 396 | let normalizedUrl = url; 397 | if (normalizedUrl.startsWith('/')) { 398 | normalizedUrl = normalizedUrl.substring(1); 399 | } 400 | 401 | // 判断是否在主页 402 | const isHomePage = currentPath.endsWith('index.html') || 403 | currentPath.endsWith('/') || 404 | currentPath.toLowerCase().endsWith('物理可视化教学/'); 405 | 406 | // 如果在主页,直接返回URL 407 | if (isHomePage) { 408 | return normalizedUrl; 409 | } 410 | 411 | // 如果在子页面,需要添加一级相对路径 412 | // 对于file://协议,我们可能需要更特殊的处理 413 | if (isFileProtocol) { 414 | // 检查当前路径是否在categories目录下 415 | if (currentPath.includes('/categories/') || currentPath.includes('\\categories\\')) { 416 | return '../' + normalizedUrl; 417 | } 418 | } 419 | 420 | // 默认添加一级目录 421 | return '../' + normalizedUrl; 422 | } -------------------------------------------------------------------------------- /categories/search.js.bak: -------------------------------------------------------------------------------- 1 | // 所有实验数据 2 | const allExperiments = [ 3 | { 4 | title: "双缝干涉", 5 | category: "光学与现代物理", 6 | url: "simulations/optics-modern/double-slit.html", 7 | tags: ["光的干涉", "衍射", "波动光学", "杨氏实验", "双缝"] 8 | }, 9 | { 10 | title: "简谐运动", 11 | category: "振动与波动", 12 | url: "simulations/waves/harmonic.html", 13 | tags: ["简谐", "振动", "周期", "频率", "弹簧振子", "单摆"] 14 | }, 15 | { 16 | title: "波的二维干涉", 17 | category: "振动与波动", 18 | url: "simulations/waves/wave2d.html", 19 | tags: ["波动", "干涉", "二维", "波面", "水波"] 20 | }, 21 | { 22 | title: "弹簧振子", 23 | category: "振动与波动", 24 | url: "simulations/waves/spring.html", 25 | tags: ["弹簧", "振动", "胡克定律", "简谐运动"] 26 | }, 27 | { 28 | title: "多普勒效应", 29 | category: "振动与波动", 30 | url: "simulations/waves/doppler.html", 31 | tags: ["多普勒", "频率移动", "声波", "波源运动"] 32 | }, 33 | { 34 | title: "机械波叠加", 35 | category: "振动与波动", 36 | url: "simulations/waves/wave-sum.html", 37 | tags: ["波叠加", "干涉", "驻波", "行波"] 38 | }, 39 | { 40 | title: "横波理解", 41 | category: "振动与波动", 42 | url: "simulations/waves/transverse.html", 43 | tags: ["横波", "波动", "振动方向", "传播方向", "波形"] 44 | }, 45 | { 46 | title: "狭义相对论", 47 | category: "光学与现代物理", 48 | url: "simulations/optics-modern/relativity/relativity.html", 49 | tags: ["爱因斯坦", "相对论", "时间膨胀", "长度收缩", "相对性"] 50 | }, 51 | { 52 | title: "动量守恒", 53 | category: "力学", 54 | url: "simulations/mechanics/momentum-conservation.html", 55 | tags: ["动量", "碰撞", "守恒", "弹性碰撞", "非弹性碰撞"] 56 | }, 57 | { 58 | title: "力的合成与分解", 59 | category: "力学", 60 | url: "simulations/mechanics/force-composition.html", 61 | tags: ["力", "合成", "分解", "平行四边形法则", "三角形法则", "分力", "合力"] 62 | }, 63 | { 64 | title: "电场可视化", 65 | category: "电磁学", 66 | url: "simulations/electromagnetism/electric-field.html", 67 | tags: ["电场", "静电场", "场强", "电力线", "点电荷"] 68 | }, 69 | { 70 | title: "磁场与电流", 71 | category: "电磁学", 72 | url: "simulations/electromagnetism/magnetic-field.html", 73 | tags: ["安培力", "磁场", "洛伦兹力", "电流", "右手定则"] 74 | }, 75 | { 76 | title: "速度选择器", 77 | category: "电磁学", 78 | url: "simulations/electromagnetism/velocity-selector.html", 79 | tags: ["电场", "磁场", "洛伦兹力", "电磁力", "带电粒子"] 80 | }, 81 | { 82 | title: "数据分析实验", 83 | category: "交叉领域与实验技能", 84 | url: "simulations/interdisciplinary/data-analysis.html", 85 | tags: ["数据", "分析", "误差", "统计", "拟合"] 86 | } 87 | ]; 88 | 89 | // 热门推荐实验 90 | const popularExperiments = [ 91 | { 92 | title: "双缝干涉", 93 | category: "光学与现代物理", 94 | url: "simulations/optics-modern/double-slit.html" 95 | }, 96 | { 97 | title: "力的合成与分解", 98 | category: "力学", 99 | url: "simulations/mechanics/force-composition.html" 100 | }, 101 | { 102 | title: "简谐运动", 103 | category: "振动与波动", 104 | url: "simulations/waves/harmonic.html" 105 | }, 106 | { 107 | title: "电场可视化", 108 | category: "电磁学", 109 | url: "simulations/electromagnetism/electric-field.html" 110 | } 111 | ]; 112 | 113 | // 创建搜索组件元素 114 | function createSearchComponent() { 115 | // 创建搜索图标 116 | const searchIcon = document.createElement('div'); 117 | searchIcon.className = 'search-icon cursor-pointer flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary hover:bg-gray-50'; 118 | searchIcon.innerHTML = ''; 119 | 120 | // 创建搜索模态框 121 | const searchModal = document.createElement('div'); 122 | searchModal.className = 'search-modal fixed inset-0 bg-gray-600 bg-opacity-50 flex items-start justify-center pt-10 md:pt-20 z-50 hidden'; 123 | 124 | // 搜索框容器 125 | const searchContainer = document.createElement('div'); 126 | searchContainer.className = 'search-container bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 overflow-hidden'; 127 | 128 | // 搜索框头部 129 | const searchHeader = document.createElement('div'); 130 | searchHeader.className = 'search-header flex items-center p-4 border-b'; 131 | searchHeader.innerHTML = ` 132 | 133 | 134 | 137 | `; 138 | 139 | // 搜索结果区域 140 | const searchResults = document.createElement('div'); 141 | searchResults.className = 'search-results max-h-60 md:max-h-80 overflow-y-auto p-4'; 142 | 143 | // 初始状态:热门推荐 144 | const popularSection = document.createElement('div'); 145 | popularSection.className = 'popular-section'; 146 | popularSection.innerHTML = ` 147 |

热门推荐

148 | 159 | `; 160 | 161 | searchResults.appendChild(popularSection); 162 | 163 | // 搜索结果区域 164 | const resultsSection = document.createElement('div'); 165 | resultsSection.className = 'results-section hidden'; 166 | resultsSection.innerHTML = '

搜索结果

'; 167 | searchResults.appendChild(resultsSection); 168 | 169 | // 组装搜索模态框 170 | searchContainer.appendChild(searchHeader); 171 | searchContainer.appendChild(searchResults); 172 | searchModal.appendChild(searchContainer); 173 | 174 | // 添加到文档中 175 | document.body.appendChild(searchModal); 176 | 177 | // 添加响应式样式 178 | const styleEl = document.createElement('style'); 179 | styleEl.textContent = ` 180 | @media (max-width: 640px) { 181 | .search-modal { 182 | padding-top: 1rem; 183 | } 184 | .search-container { 185 | max-width: 95%; 186 | margin: 0 2.5%; 187 | } 188 | .search-results { 189 | max-height: 60vh; 190 | } 191 | } 192 | `; 193 | document.head.appendChild(styleEl); 194 | 195 | // 搜索逻辑 196 | const searchInputEl = searchModal.querySelector('.search-input'); 197 | const resultsItemsEl = searchModal.querySelector('.results-items'); 198 | const popularSectionEl = searchModal.querySelector('.popular-section'); 199 | const resultsSectionEl = searchModal.querySelector('.results-section'); 200 | 201 | // 添加事件监听 202 | searchIcon.addEventListener('click', () => { 203 | searchModal.classList.remove('hidden'); 204 | setTimeout(() => searchInputEl.focus(), 100); 205 | }); 206 | 207 | searchModal.querySelector('.search-close').addEventListener('click', () => { 208 | searchModal.classList.add('hidden'); 209 | }); 210 | 211 | // 点击模态框背景关闭 212 | searchModal.addEventListener('click', (e) => { 213 | if (e.target === searchModal) { 214 | searchModal.classList.add('hidden'); 215 | } 216 | }); 217 | 218 | // 搜索输入框事件 219 | searchInputEl.addEventListener('input', (e) => { 220 | const query = e.target.value.trim().toLowerCase(); 221 | 222 | if (query === '') { 223 | // 显示热门推荐 224 | popularSectionEl.classList.remove('hidden'); 225 | resultsSectionEl.classList.add('hidden'); 226 | return; 227 | } 228 | 229 | // 隐藏热门推荐,显示搜索结果 230 | popularSectionEl.classList.add('hidden'); 231 | resultsSectionEl.classList.remove('hidden'); 232 | 233 | // 模糊搜索逻辑 234 | const results = allExperiments.filter(exp => { 235 | // 搜索标题 236 | if (exp.title.toLowerCase().includes(query)) return true; 237 | 238 | // 搜索分类 239 | if (exp.category.toLowerCase().includes(query)) return true; 240 | 241 | // 搜索标签 242 | return exp.tags.some(tag => tag.toLowerCase().includes(query)); 243 | }); 244 | 245 | // 更新搜索结果 246 | if (results.length === 0) { 247 | resultsItemsEl.innerHTML = '
没有找到匹配的实验
'; 248 | } else { 249 | resultsItemsEl.innerHTML = results.map(exp => ` 250 | 251 |
252 |
253 |
${highlightMatch(exp.title, query)}
254 |
${exp.category}
255 |
256 |
257 | `).join(''); 258 | 259 | // 添加点击事件监听器 260 | resultsItemsEl.querySelectorAll('.experiment-item').forEach(item => { 261 | item.addEventListener('click', function(e) { 262 | // 先关闭搜索框,避免跳转问题 263 | searchModal.classList.add('hidden'); 264 | 265 | // 如果是移动设备,先延迟一下再跳转,确保模态框已关闭 266 | if (window.innerWidth < 768) { 267 | e.preventDefault(); 268 | const href = this.getAttribute('href'); 269 | setTimeout(() => { 270 | window.location.href = href; 271 | }, 100); 272 | } 273 | }); 274 | }); 275 | } 276 | }); 277 | 278 | // 键盘ESC关闭搜索 279 | document.addEventListener('keydown', (e) => { 280 | if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) { 281 | searchModal.classList.add('hidden'); 282 | } 283 | }); 284 | 285 | return searchIcon; 286 | } 287 | 288 | // 高亮匹配的文字 289 | function highlightMatch(text, query) { 290 | if (!query) return text; 291 | 292 | const lowerText = text.toLowerCase(); 293 | const lowerQuery = query.toLowerCase(); 294 | 295 | if (!lowerText.includes(lowerQuery)) return text; 296 | 297 | const index = lowerText.indexOf(lowerQuery); 298 | const before = text.substring(0, index); 299 | const match = text.substring(index, index + query.length); 300 | const after = text.substring(index + query.length); 301 | 302 | return `${before}${match}${after}`; 303 | } 304 | 305 | // 初始化搜索组件 306 | function initSearchComponent() { 307 | // 根据当前页面使用相应的主题颜色 308 | updateSearchThemeColor(); 309 | 310 | // 获取导航栏,更通用的选择器以同时支持主页和子页面 311 | const navbar = document.querySelector('nav'); 312 | if (navbar) { 313 | // 尝试寻找子页面的右侧导航区域 314 | let navbarRightSection = navbar.querySelector('.flex.items-center.space-x-4'); 315 | 316 | // 如果找不到子页面的格式,尝试寻找主页面的导航区域 317 | if (!navbarRightSection) { 318 | navbarRightSection = navbar.querySelector('.hidden.md\\:flex.items-center.space-x-4'); 319 | } 320 | 321 | // 如果还是找不到,就尝试创建一个新的导航区域 322 | if (!navbarRightSection) { 323 | // 尝试找到导航栏的容器 324 | const navContainer = navbar.querySelector('.flex.justify-between'); 325 | if (navContainer) { 326 | navbarRightSection = document.createElement('div'); 327 | navbarRightSection.className = 'flex items-center space-x-4'; 328 | navContainer.appendChild(navbarRightSection); 329 | } 330 | } 331 | 332 | if (navbarRightSection) { 333 | const searchIcon = createSearchComponent(); 334 | navbarRightSection.insertBefore(searchIcon, navbarRightSection.firstChild); 335 | } else { 336 | console.warn('找不到合适的导航区域来放置搜索图标'); 337 | } 338 | } 339 | } 340 | 341 | // 更新搜索组件的主题颜色以匹配当前页面 342 | function updateSearchThemeColor() { 343 | // 根据当前页面设置相应的主题颜色 344 | let primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim(); 345 | 346 | // 如果找不到颜色值,使用默认蓝色 347 | if (!primaryColor) { 348 | primaryColor = '#5E6AD2'; 349 | } 350 | 351 | // 更新CSS变量 352 | document.documentElement.style.setProperty('--search-primary', primaryColor); 353 | 354 | // 动态添加样式 355 | const styleEl = document.createElement('style'); 356 | styleEl.textContent = ` 357 | .search-icon:hover { color: var(--search-primary) !important; } 358 | .search-results .icon { color: var(--search-primary) !important; } 359 | .search-input:focus { border-color: var(--search-primary) !important; } 360 | `; 361 | document.head.appendChild(styleEl); 362 | } 363 | 364 | // 在DOM加载完成后初始化搜索组件 365 | document.addEventListener('DOMContentLoaded', initSearchComponent); 366 | 367 | // 适配不同页面的路径问题 368 | function getCorrectPath(url) { 369 | // 检查当前URL是否是file://协议打开的本地文件 370 | const isFileProtocol = window.location.protocol === 'file:'; 371 | 372 | // 获取当前页面的路径 373 | const currentPath = window.location.pathname; 374 | 375 | // 标准化URL(去除可能的前导斜杠) 376 | let normalizedUrl = url; 377 | if (normalizedUrl.startsWith('/')) { 378 | normalizedUrl = normalizedUrl.substring(1); 379 | } 380 | 381 | // 判断是否在主页 382 | const isHomePage = currentPath.endsWith('index.html') || 383 | currentPath.endsWith('/') || 384 | currentPath.toLowerCase().endsWith('物理可视化教学/'); 385 | 386 | // 如果在主页,直接返回URL 387 | if (isHomePage) { 388 | return normalizedUrl; 389 | } 390 | 391 | // 如果在子页面,需要添加一级相对路径 392 | // 对于file://协议,我们可能需要更特殊的处理 393 | if (isFileProtocol) { 394 | // 检查当前路径是否在categories目录下 395 | if (currentPath.includes('/categories/') || currentPath.includes('\\categories\\')) { 396 | return '../' + normalizedUrl; 397 | } 398 | } 399 | 400 | // 默认添加一级目录 401 | return '../' + normalizedUrl; 402 | } -------------------------------------------------------------------------------- /physics-project-progress.md: -------------------------------------------------------------------------------- 1 | # 物理可视化项目开发进度 2 | 3 | 这个文档追踪物理可视化教学项目的开发进度,包括已完成的模块和待开发的模块。 4 | 5 | ## 力学(Mechanics) 6 | 7 | ### 已开发模块 8 | - 动量守恒 - 一维碰撞 9 | - 动量守恒 - 二维碰撞(未开发) 10 | - 力的合成与分解 11 | 12 | ### 待开发模块 13 | - 斜面运动(未开发) 14 | - 能量守恒(未开发) 15 | - 牛顿摆(未开发) 16 | - 万有引力与轨道运动(未开发) 17 | 18 | ## 电磁学(Electromagnetism) 19 | 20 | ### 已开发模块 21 | - 速度选择器 22 | - 电场可视化 23 | - 磁场与电流 24 | 25 | ### 待开发模块 26 | - 电路模拟实验(未开发) 27 | - 电磁感应(未开发) 28 | - 电磁波传播(未开发) 29 | - 电磁力应用(未开发) 30 | 31 | ## 振动与波动(Vibration and Waves) 32 | 33 | ### 已开发模块 34 | - 横波理解(transverse) 35 | - 波的二维干涉(wave2d) 36 | - 波的叠加(wave-sum) 37 | - 弹簧振子(spring) 38 | - 多普勒效应(doppler) 39 | - 简谐运动(harmonic) 40 | 41 | ### 待开发模块 42 | - 共振现象(未开发) 43 | - 单摆实验(未开发) 44 | 45 | ## 光学与现代物理(Optics and Modern Physics) 46 | 47 | ### 已开发模块 48 | - 双缝干涉 49 | - 狭义相对论 50 | 51 | ### 待开发模块 52 | - 几何光学(未开发) 53 | - 光电效应(未开发) 54 | 55 | ## 跨学科与实验技能(Interdisciplinary and Experimental Skills) 56 | 57 | ### 已开发模块 58 | - 数据分析与误差处理 59 | 60 | ### 待开发模块 61 | - 游标卡尺(未开发) 62 | - 螺旋测微器(未开发) 63 | - 光电门测速(未开发) 64 | - 打点计时器测速(未开发) 65 | 66 | ## 项目统计 67 | 68 | - 总项目数:24 69 | - 已完成项目:13 70 | - 未开发项目:11 71 | - 完成率:约 54.2% -------------------------------------------------------------------------------- /simulations/mechanics/force-composition.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 力的合成与分解 - 物理可视化教学 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 106 | 107 | 108 | 109 | 133 | 134 | 135 |
136 |
137 | 138 |
139 |
140 |

实验参数控制

141 | 142 |
143 | 144 |
145 | 146 |
147 | 150 | 153 |
154 |
155 | 156 | 157 |
158 | 159 |
160 | 163 | 166 |
167 |
168 | 169 |

力1参数(蓝色)

170 | 171 |
172 | 176 |
177 | 178 | 180 |
181 |
182 | 183 | 184 |
185 | 189 |
190 | 191 | 193 |
194 |
195 | 196 |

力2参数(橙色)

197 | 198 |
199 | 203 |
204 | 205 | 207 |
208 |
209 | 210 | 211 |
212 | 216 |
217 | 218 | 220 |
221 |
222 | 223 | 224 |
225 |
226 | 227 | 228 |
229 |
230 | 231 | 232 |
233 |
234 | 235 | 236 |
237 |
238 | 239 | 240 |
241 | 242 | 249 |
250 | 251 | 252 |
253 | 256 | 259 |
260 |
261 |
262 | 263 | 264 |
265 |

分析结果

266 | 267 |
268 |
269 | 合力计算结果: 270 |
271 | 272 |
273 |
合力大小:
274 |
--
275 |
276 | 277 |
278 |
合力方向:
279 |
--
280 |
281 | 282 |
283 |
水平分量:
284 |
--
285 |
286 | 287 |
288 |
垂直分量:
289 |
--
290 |
291 |
292 |
293 |
294 | 295 | 296 |
297 | 298 |
299 | 300 |
301 | 302 | 303 |
304 |

力的合成与分解原理

305 |

306 | 力的合成与分解是研究多个力作用效果的重要方法。通过平行四边形法则或三角形法则,我们可以将多个力合成为一个合力,也可以将一个力分解为沿不同方向的分力。 307 |

308 |
309 | F = √(F₁² + F₂² + 2F₁F₂cosθ) 310 |
311 |

312 | 在本实验中,你可以通过调节力的大小和方向,直观地观察力的合成与分解过程。通过显示分量和网格,可以更好地理解力在不同方向上的分布。 313 |

314 |
315 |
316 |
317 |
318 | 319 | 320 | 328 | 329 | 330 | 331 | 332 | -------------------------------------------------------------------------------- /simulations/mechanics/momentum-conservation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 动量守恒模型 - 物理可视化教学 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 97 | 98 | 99 | 100 | 124 | 125 | 126 |
127 | 128 |
129 |
130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | m₁v₁ + m₂v₂ = 常量 148 | 149 | 150 |
151 |

152 | 动量守恒定律描述了在没有外力作用的系统中,系统总动量保持不变。无论是碰撞、爆炸还是其他相互作用,总动量始终守恒。 153 |

154 |
155 | 156 | 157 |
158 |

选择实验模型

159 | 233 |
234 | 235 | 236 |
237 |

动量守恒定律解析

238 | 239 |
240 |
241 |

基本原理

242 |

243 | 动量守恒定律是物理学中最基本的守恒定律之一,表述为:在一个没有外力作用的系统中,系统的总动量保持不变。 244 |

245 |

246 | 动量定义为质量与速度的乘积:p = mv,是一个矢量。对于多粒子系统,总动量等于各个粒子动量的矢量和。 247 |

248 |
249 |

数学表达

250 |

对于一个n个粒子组成的系统,总动量守恒可表示为:

251 |
252 | p1 + p2 + ... + pn = 常数 253 |
254 |
255 | m1v1 + m2v2 + ... + mnvn = 常数 256 |
257 |
258 |
259 | 260 |
261 |

应用场景

262 |
    263 |
  • 碰撞问题:如台球、分子碰撞等
  • 264 |
  • 爆炸和分裂:如炮弹爆炸、原子核裂变等
  • 265 |
  • 推进系统:如火箭、喷气式飞机等
  • 266 |
  • 流体动力学:如水射流、喷气推进等
  • 267 |
  • 天体运动:如行星系统、双星系统等
  • 268 |
269 | 270 |
271 |

关键概念

272 |
    273 |
  • 内力不改变系统总动量
  • 274 |
  • 冲量等于动量变化量
  • 275 |
  • 动量守恒与牛顿第三定律密切相关
  • 276 |
  • 质心运动不受内力影响
  • 277 |
278 |
279 |
280 |
281 |
282 |
283 | 284 | 285 | 293 | 294 | 295 | 314 | 315 | -------------------------------------------------------------------------------- /simulations/mechanics/momentum-conservation/1d-collision.js: -------------------------------------------------------------------------------- 1 | // 全局变量 2 | let balls = []; 3 | let wallLeft, wallRight; 4 | let simulationStarted = false; 5 | let collisionHappened = false; 6 | let initialMomentum, finalMomentum; 7 | let initialEnergy, finalEnergy; 8 | let collisionType = 'elastic'; 9 | let restitutionCoefficient = 0.5; 10 | 11 | // DOM元素引用 12 | const leftMassSlider = document.getElementById('left-mass'); 13 | const leftMassValue = document.getElementById('left-mass-value'); 14 | const leftVelocitySlider = document.getElementById('left-velocity'); 15 | const leftVelocityValue = document.getElementById('left-velocity-value'); 16 | const rightMassSlider = document.getElementById('right-mass'); 17 | const rightMassValue = document.getElementById('right-mass-value'); 18 | const rightVelocitySlider = document.getElementById('right-velocity'); 19 | const rightVelocityValue = document.getElementById('right-velocity-value'); 20 | const startBtn = document.getElementById('start-btn'); 21 | const resetBtn = document.getElementById('reset-btn'); 22 | const initialMomentumSpan = document.getElementById('initial-momentum'); 23 | const finalMomentumSpan = document.getElementById('final-momentum'); 24 | const initialEnergySpan = document.getElementById('initial-energy'); 25 | const finalEnergySpan = document.getElementById('final-energy'); 26 | const momentumConservationSpan = document.getElementById('momentum-conservation'); 27 | const energyConservationSpan = document.getElementById('energy-conservation'); 28 | const elasticRadio = document.getElementById('elastic'); 29 | const inelasticRadio = document.getElementById('inelastic'); 30 | const perfectlyInelasticRadio = document.getElementById('perfectly-inelastic'); 31 | const coefficientContainer = document.getElementById('coefficient-container'); 32 | const coefficientSlider = document.getElementById('coefficient'); 33 | const coefficientValue = document.getElementById('coefficient-value'); 34 | 35 | // 碰撞类型选择事件 36 | elasticRadio.addEventListener('change', function() { 37 | if (this.checked) { 38 | collisionType = 'elastic'; 39 | coefficientContainer.classList.add('hidden'); 40 | resetSimulation(); 41 | } 42 | }); 43 | 44 | inelasticRadio.addEventListener('change', function() { 45 | if (this.checked) { 46 | collisionType = 'inelastic'; 47 | coefficientContainer.classList.remove('hidden'); 48 | restitutionCoefficient = parseFloat(coefficientSlider.value); 49 | resetSimulation(); 50 | } 51 | }); 52 | 53 | perfectlyInelasticRadio.addEventListener('change', function() { 54 | if (this.checked) { 55 | collisionType = 'perfectly-inelastic'; 56 | coefficientContainer.classList.add('hidden'); 57 | resetSimulation(); 58 | } 59 | }); 60 | 61 | // 恢复系数滑块事件 62 | coefficientSlider.addEventListener('input', function() { 63 | restitutionCoefficient = parseFloat(this.value); 64 | coefficientValue.textContent = restitutionCoefficient; 65 | resetSimulation(); 66 | }); 67 | 68 | // 左侧球质量滑块事件 69 | leftMassSlider.addEventListener('input', function() { 70 | leftMassValue.textContent = this.value; 71 | resetSimulation(); 72 | }); 73 | 74 | // 左侧球速度滑块事件 75 | leftVelocitySlider.addEventListener('input', function() { 76 | leftVelocityValue.textContent = this.value; 77 | resetSimulation(); 78 | }); 79 | 80 | // 右侧球质量滑块事件 81 | rightMassSlider.addEventListener('input', function() { 82 | rightMassValue.textContent = this.value; 83 | resetSimulation(); 84 | }); 85 | 86 | // 右侧球速度滑块事件 87 | rightVelocitySlider.addEventListener('input', function() { 88 | rightVelocityValue.textContent = this.value; 89 | resetSimulation(); 90 | }); 91 | 92 | // 开始按钮事件 93 | startBtn.addEventListener('click', function() { 94 | if (!simulationStarted) { 95 | simulationStarted = true; 96 | initialMomentum = calculateTotalMomentum(); 97 | initialEnergy = calculateTotalEnergy(); 98 | 99 | // 更新初始动量和能量显示 100 | initialMomentumSpan.textContent = initialMomentum.toFixed(2) + ' kg·m/s'; 101 | initialEnergySpan.textContent = initialEnergy.toFixed(2) + ' J'; 102 | 103 | // 更改按钮文本 104 | startBtn.innerHTML = '暂停模拟'; 105 | } else { 106 | simulationStarted = false; 107 | startBtn.innerHTML = '继续模拟'; 108 | } 109 | }); 110 | 111 | // 重置按钮事件 112 | resetBtn.addEventListener('click', resetSimulation); 113 | 114 | // 重置模拟 115 | function resetSimulation() { 116 | simulationStarted = false; 117 | collisionHappened = false; 118 | initialMomentum = null; 119 | finalMomentum = null; 120 | initialEnergy = null; 121 | finalEnergy = null; 122 | 123 | // 重置数据显示 124 | initialMomentumSpan.textContent = '0 kg·m/s'; 125 | finalMomentumSpan.textContent = '0 kg·m/s'; 126 | initialEnergySpan.textContent = '0 J'; 127 | finalEnergySpan.textContent = '0 J'; 128 | momentumConservationSpan.textContent = '是'; 129 | momentumConservationSpan.className = 'text-sm font-medium text-green-600'; 130 | energyConservationSpan.textContent = '是'; 131 | energyConservationSpan.className = 'text-sm font-medium text-green-600'; 132 | 133 | // 重置按钮文本 134 | startBtn.innerHTML = '开始模拟'; 135 | 136 | // 重新创建小球 137 | createBalls(); 138 | } 139 | 140 | // 创建小球 141 | function createBalls() { 142 | balls = []; 143 | 144 | // 计算画布尺寸 145 | const canvasWidth = width; 146 | const canvasHeight = height; 147 | 148 | // 确保有有效的画布尺寸 149 | if(!canvasWidth || !canvasHeight) { 150 | console.error("Canvas dimensions not available"); 151 | return; 152 | } 153 | 154 | console.log("Canvas dimensions:", canvasWidth, "x", canvasHeight); 155 | 156 | // 设置墙壁位置 157 | wallLeft = 60; 158 | wallRight = canvasWidth - 60; 159 | 160 | // 减小小球半径计算系数,防止过大 161 | const radiusScale = 1.5; // 进一步减小半径系数 162 | 163 | // 创建左侧小球 - 位置更靠左 164 | const leftBallMass = parseInt(leftMassSlider.value); 165 | const leftBallVelocity = parseFloat(leftVelocitySlider.value); 166 | const leftBallRadius = 12 + radiusScale * leftBallMass; // 进一步减小基础半径 167 | const leftBall = { 168 | position: createVector(canvasWidth * 0.25, canvasHeight / 2), 169 | velocity: createVector(leftBallVelocity, 0), 170 | mass: leftBallMass, 171 | radius: leftBallRadius, 172 | color: color(59, 130, 246), // 蓝色 173 | isStuck: false 174 | }; 175 | 176 | // 创建右侧小球 - 位置更靠右 177 | const rightBallMass = parseInt(rightMassSlider.value); 178 | const rightBallVelocity = parseFloat(rightVelocitySlider.value); 179 | const rightBallRadius = 12 + radiusScale * rightBallMass; // 进一步减小基础半径 180 | const rightBall = { 181 | position: createVector(canvasWidth * 0.75, canvasHeight / 2), 182 | velocity: createVector(rightBallVelocity, 0), 183 | mass: rightBallMass, 184 | radius: rightBallRadius, 185 | color: color(239, 68, 68), // 红色 186 | isStuck: false 187 | }; 188 | 189 | balls.push(leftBall, rightBall); 190 | } 191 | 192 | // 计算总动量 193 | function calculateTotalMomentum() { 194 | let totalMomentum = 0; 195 | 196 | for (const ball of balls) { 197 | totalMomentum += ball.mass * ball.velocity.x; 198 | } 199 | 200 | return totalMomentum; 201 | } 202 | 203 | // 计算总动能 204 | function calculateTotalEnergy() { 205 | let totalEnergy = 0; 206 | 207 | for (const ball of balls) { 208 | totalEnergy += 0.5 * ball.mass * ball.velocity.mag() * ball.velocity.mag(); 209 | } 210 | 211 | return totalEnergy; 212 | } 213 | 214 | // 检查碰撞并处理 215 | function checkCollisions() { 216 | const ball1 = balls[0]; 217 | const ball2 = balls[1]; 218 | 219 | // 如果小球已经粘在一起,按照完全非弹性碰撞规则移动 220 | if (ball1.isStuck && ball2.isStuck) { 221 | ball2.position.x = ball1.position.x + ball1.radius; 222 | ball2.velocity = ball1.velocity.copy(); 223 | return; 224 | } 225 | 226 | // 计算两球距离 227 | const distance = p5.Vector.dist(ball1.position, ball2.position); 228 | 229 | // 检测是否发生碰撞 230 | if (distance <= ball1.radius + ball2.radius) { 231 | if (!collisionHappened) { 232 | // 计算碰撞后速度 233 | let v1 = ball1.velocity.copy(); 234 | let v2 = ball2.velocity.copy(); 235 | let m1 = ball1.mass; 236 | let m2 = ball2.mass; 237 | 238 | // 根据碰撞类型处理 239 | if (collisionType === 'elastic') { 240 | // 弹性碰撞 241 | ball1.velocity.x = ((m1 - m2) / (m1 + m2)) * v1.x + ((2 * m2) / (m1 + m2)) * v2.x; 242 | ball2.velocity.x = ((2 * m1) / (m1 + m2)) * v1.x + ((m2 - m1) / (m1 + m2)) * v2.x; 243 | } else if (collisionType === 'inelastic') { 244 | // 非弹性碰撞 245 | let e = restitutionCoefficient; // 恢复系数 246 | 247 | let v1Final = ((m1 - e * m2) * v1.x + (1 + e) * m2 * v2.x) / (m1 + m2); 248 | let v2Final = ((1 + e) * m1 * v1.x + (m2 - e * m1) * v2.x) / (m1 + m2); 249 | 250 | ball1.velocity.x = v1Final; 251 | ball2.velocity.x = v2Final; 252 | } else if (collisionType === 'perfectly-inelastic') { 253 | // 完全非弹性碰撞 - 两球粘在一起 254 | let commonVelocity = (m1 * v1.x + m2 * v2.x) / (m1 + m2); 255 | ball1.velocity.x = commonVelocity; 256 | ball2.velocity.x = commonVelocity; 257 | ball1.isStuck = true; 258 | ball2.isStuck = true; 259 | } 260 | 261 | // 分离两球以避免重叠 262 | const overlap = ball1.radius + ball2.radius - distance; 263 | const separationVector = p5.Vector.sub(ball2.position, ball1.position).normalize().mult(overlap / 2); 264 | 265 | // 如果不是完全非弹性碰撞,分离小球;否则保持粘在一起 266 | if (collisionType !== 'perfectly-inelastic') { 267 | ball1.position.sub(separationVector); 268 | ball2.position.add(separationVector); 269 | } else { 270 | ball2.position = p5.Vector.add(ball1.position, p5.Vector.mult(separationVector.normalize(), ball1.radius + ball2.radius)); 271 | } 272 | 273 | // 标记碰撞已发生,计算碰撞后动量和能量 274 | collisionHappened = true; 275 | finalMomentum = calculateTotalMomentum(); 276 | finalEnergy = calculateTotalEnergy(); 277 | 278 | // 更新显示数据 279 | finalMomentumSpan.textContent = finalMomentum.toFixed(2) + ' kg·m/s'; 280 | finalEnergySpan.textContent = finalEnergy.toFixed(2) + ' J'; 281 | 282 | // 检查动量守恒 283 | const momentumDiff = Math.abs(finalMomentum - initialMomentum); 284 | if (momentumDiff > 0.01) { 285 | momentumConservationSpan.textContent = '否'; 286 | momentumConservationSpan.className = 'text-sm font-medium text-red-600'; 287 | } 288 | 289 | // 检查能量守恒 290 | const energyRatio = finalEnergy / initialEnergy; 291 | if (collisionType === 'elastic') { 292 | if (Math.abs(energyRatio - 1) > 0.01) { 293 | energyConservationSpan.textContent = '否'; 294 | energyConservationSpan.className = 'text-sm font-medium text-red-600'; 295 | } 296 | } else { 297 | // 非弹性碰撞应该损失能量 298 | energyConservationSpan.textContent = '否(预期)'; 299 | energyConservationSpan.className = 'text-sm font-medium text-amber-600'; 300 | } 301 | } 302 | } 303 | 304 | // 检测墙壁碰撞 305 | for (const ball of balls) { 306 | // 左墙 307 | if (ball.position.x - ball.radius < wallLeft) { 308 | ball.position.x = wallLeft + ball.radius; 309 | ball.velocity.x *= -1; 310 | } 311 | 312 | // 右墙 313 | if (ball.position.x + ball.radius > wallRight) { 314 | ball.position.x = wallRight - ball.radius; 315 | ball.velocity.x *= -1; 316 | } 317 | } 318 | } 319 | 320 | // 更新小球位置 321 | function updateBalls() { 322 | for (const ball of balls) { 323 | ball.position.add(p5.Vector.mult(ball.velocity, deltaTime / 20)); 324 | } 325 | } 326 | 327 | // p5.js 设置 328 | function setup() { 329 | // 获取容器元素 330 | const container = document.getElementById('simulation-container'); 331 | 332 | // 创建画布填充整个容器 333 | const canvas = createCanvas(container.offsetWidth, container.offsetHeight); 334 | canvas.parent('canvas-container'); 335 | 336 | // 设置文本对齐方式 337 | textAlign(CENTER, CENTER); 338 | 339 | // 设置帧率 340 | frameRate(60); 341 | 342 | // 创建小球 343 | createBalls(); 344 | } 345 | 346 | // p5.js 窗口调整大小 347 | function windowResized() { 348 | const container = document.getElementById('simulation-container'); 349 | resizeCanvas(container.offsetWidth, container.offsetHeight); 350 | resetSimulation(); 351 | } 352 | 353 | // p5.js 绘制 354 | function draw() { 355 | background(255); 356 | 357 | // 绘制轨道 358 | strokeWeight(4); 359 | stroke(200); 360 | line(wallLeft, height / 2, wallRight, height / 2); 361 | 362 | // 绘制墙壁 363 | strokeWeight(2); 364 | stroke(100); 365 | line(wallLeft, height / 6, wallLeft, height * 5 / 6); 366 | line(wallRight, height / 6, wallRight, height * 5 / 6); 367 | 368 | // 如果模拟已经开始,更新小球位置和检查碰撞 369 | if (simulationStarted) { 370 | checkCollisions(); 371 | updateBalls(); 372 | } 373 | 374 | // 绘制小球 375 | noStroke(); 376 | for (const ball of balls) { 377 | fill(ball.color); 378 | ellipse(ball.position.x, ball.position.y, ball.radius * 2); 379 | 380 | // 绘制方向指示箭头 381 | if (ball.velocity.mag() > 0.1) { 382 | drawArrow(ball.position, p5.Vector.add(ball.position, p5.Vector.mult(ball.velocity, 3)), ball.color); 383 | } 384 | 385 | // 绘制小球质量标签 386 | fill(0); 387 | textSize(12); 388 | text(ball.mass + 'kg', ball.position.x, ball.position.y - ball.radius - 15); 389 | 390 | // 绘制小球速度标签 391 | text(ball.velocity.x.toFixed(1) + ' m/s', ball.position.x, ball.position.y + ball.radius + 15); 392 | } 393 | } 394 | 395 | // 绘制箭头 396 | function drawArrow(base, vec, arrowColor) { 397 | push(); 398 | stroke(arrowColor); 399 | strokeWeight(3); 400 | fill(arrowColor); 401 | 402 | line(base.x, base.y, vec.x, vec.y); 403 | 404 | const arrowSize = 8; 405 | const angle = atan2(vec.y - base.y, vec.x - base.x); 406 | 407 | translate(vec.x, vec.y); 408 | rotate(angle); 409 | triangle(0, arrowSize / 2, 0, -arrowSize / 2, arrowSize, 0); 410 | 411 | pop(); 412 | } 413 | 414 | // 初始化页面加载完成后 415 | window.addEventListener('load', function() { 416 | // 初始化滑块显示值 417 | leftMassValue.textContent = leftMassSlider.value; 418 | leftVelocityValue.textContent = leftVelocitySlider.value; 419 | rightMassValue.textContent = rightMassSlider.value; 420 | rightVelocityValue.textContent = rightVelocitySlider.value; 421 | coefficientValue.textContent = coefficientSlider.value; 422 | }); -------------------------------------------------------------------------------- /simulations/mechanics/momentum-conservation/2d-collision.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 二维碰撞模拟 - 物理可视化教学 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 103 | 104 | 105 | 106 | 130 | 131 | 132 |
133 | 134 |
135 | 136 |
137 | 138 |
139 |
140 |
141 | 142 |

即将推出

143 |

144 | 二维碰撞模拟正在开发中,敬请期待!此模拟将包含多个质量不同的小球在二维平面内的弹性碰撞和非弹性碰撞行为。 145 |

146 |
147 |
148 |
149 | 150 | 151 |
152 |

计划功能

153 | 154 |
155 | 156 |
157 |

物理特性

158 |
    159 |
  • 160 | 161 | 多小球二维弹性碰撞 162 |
  • 163 |
  • 164 | 165 | 不同质量和半径的小球 166 |
  • 167 |
  • 168 | 169 | 动量矢量分析 170 |
  • 171 |
  • 172 | 173 | 角动量守恒验证 174 |
  • 175 |
176 |
177 | 178 | 179 |
180 |

交互功能

181 |
    182 |
  • 183 | 184 | 鼠标拖动改变小球初速度 185 |
  • 186 |
  • 187 | 188 | 添加/移除小球 189 |
  • 190 |
  • 191 | 192 | 调整小球质量和恢复系数 193 |
  • 194 |
  • 195 | 196 | 显示/隐藏速度和动量矢量 197 |
  • 198 |
199 |
200 |
201 |
202 |
203 | 204 | 205 |
206 |

二维碰撞理论

207 | 208 |
209 |
210 |

动量守恒原理

211 |

212 | 在二维碰撞中,动量守恒被应用于x方向和y方向,使得碰撞前后的总动量在两个方向上分别保持不变: 213 |

214 |
215 | (m₁v₁ₓ + m₂v₂ₓ) = (m₁v₁ₓ' + m₂v₂ₓ')
216 | (m₁v₁ᵧ + m₂v₂ᵧ) = (m₁v₁ᵧ' + m₂v₂ᵧ') 217 |
218 |
219 | 220 |
221 |

能量守恒原理

222 |

223 | 在弹性碰撞中,动能也守恒,即碰撞前后的总动能保持不变: 224 |

225 |
226 | ½m₁|v₁|² + ½m₂|v₂|² = ½m₁|v₁'|² + ½m₂|v₂'|² 227 |
228 |
229 | 230 |
231 |

二维碰撞解析

232 |

233 | 在二维碰撞分析中,通常将问题分解为沿碰撞线(碰撞时两球中心连线)和垂直于碰撞线的两个分量: 234 |

235 |
    236 |
  1. 沿碰撞线方向的速度分量按照一维碰撞处理
  2. 237 |
  3. 垂直于碰撞线的速度分量在碰撞前后保持不变
  4. 238 |
  5. 最后将这两个分量重新合成为碰撞后的速度向量
  6. 239 |
240 |
241 | 242 |
243 |

应用场景

244 |
    245 |
  • 246 | 247 | 分子动力学模拟 248 |
  • 249 |
  • 250 | 251 | 物理游戏引擎 252 |
  • 253 |
  • 254 | 255 | 台球和弹球物理 256 |
  • 257 |
  • 258 | 259 | 天体物理学 260 |
  • 261 |
262 |
263 |
264 |
265 |
266 |
267 | 268 | 269 | 277 | 278 | -------------------------------------------------------------------------------- /simulations/mechanics/momentum-conservation/explosion.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 爆炸演示 - 动量守恒模型 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 102 | 103 | 104 | 105 | 123 | 124 | 125 |
126 |
127 | 128 |
129 |
130 |

实验参数控制

131 | 132 |
133 | 134 |
135 | 139 | 141 |
142 | 143 | 144 |
145 | 149 | 151 |
152 | 153 | 154 |
155 | 159 | 161 |
162 | 163 | 164 |
165 | 166 |
167 |
168 | 169 | 170 |
171 |
172 | 173 | 174 |
175 |
176 |
177 | 178 | 179 |
180 | 183 | 188 |
189 | 190 | 191 |
192 | 196 | 198 |
199 | 200 | 201 |
202 |
203 | 204 | 205 |
206 |
207 | 208 | 209 |
210 |
211 | 212 | 213 |
214 |
215 | 216 | 217 |
218 | 221 | 224 |
225 |
226 |
227 | 228 | 229 |
230 |

物理量测量

231 | 232 |
233 |
234 |
爆炸前总动量:
235 |
236 | p₀ = 0 kg·m/s 237 |
238 |
239 | 240 |
241 |
爆炸后总动量:
242 |
243 | p = 0 kg·m/s 244 |
245 |
246 | 247 |
248 |
总动量守恒误差:
249 |
250 | 误差 = 0% 251 |
252 |
253 | 254 |
255 |
碎片信息:
256 |
257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 |
碎片质量(kg)速度(m/s)动量(kg·m/s)
270 |
271 |
272 |
273 |
274 |
275 | 276 | 277 |
278 | 279 |
280 | 281 |
282 | 283 | 284 |
285 |

实验原理

286 |

爆炸是动量守恒的典型应用场景。在爆炸前,物体静止,总动量为零。爆炸后,碎片向各个方向运动,但由于系统不受外力作用,总动量仍然为零。

287 | 288 |
289 |

动量守恒定律

290 |

在没有外力作用的系统中,爆炸前后总动量保持不变。对于静止的初始物体,爆炸后各碎片动量之和应为零:

291 |
292 | m₁v₁ + m₂v₂ + ... + mₙvₙ = 0 293 |
294 |
295 | 296 |

实验说明

297 |
    298 |
  1. 调整初始物体的质量、碎片数量和爆炸强度等参数
  2. 299 |
  3. 点击"开始"按钮触发爆炸
  4. 300 |
  5. 观察爆炸动画和碎片的运动情况
  6. 301 |
  7. 开启轨迹显示可以观察碎片的运动路径
  8. 302 |
  9. 验证爆炸前后总动量是否守恒
  10. 303 |
304 | 305 |
306 |

思考问题

307 |
    308 |
  • 当改变碎片质量分布方式时,爆炸效果有何不同?
  • 309 |
  • 增加爆炸强度会如何影响碎片的运动?
  • 310 |
  • 爆炸过程中动能和势能如何转换?
  • 311 |
  • 为什么爆炸后碎片的总动量仍然保持为零?
  • 312 |
313 |
314 |
315 |
316 |
317 |
318 | 319 | 320 | 335 | 336 | 337 | 338 | 339 | -------------------------------------------------------------------------------- /simulations/optics-modern/relativity/length-contraction.js: -------------------------------------------------------------------------------- 1 | // 相对论可视化模拟 - 长度收缩模块 2 | 3 | // 长度收缩可视化 4 | function setupLengthContractionSketch() { 5 | // 确保容器存在 6 | const container = document.getElementById('length-contraction-animation'); 7 | if (!container) { 8 | console.error('找不到长度收缩动画容器'); 9 | return; 10 | } 11 | 12 | lengthContractionSketch = new p5((sketch) => { 13 | // 动画参数 14 | let time = 0; 15 | let lastTime = 0; 16 | let trainPosition = 0; 17 | let trainSpeed = 0; 18 | let prevPlayState = false; // 记录上一帧的播放状态 19 | let pausePosition = 0; // 记录暂停时的列车位置 20 | 21 | // 参考系颜色 - 使用更鲜明的颜色 22 | const staticColor = sketch.color(65, 105, 225); // 皇家蓝 23 | const movingColor = sketch.color(46, 204, 113); // 翡翠绿 24 | const rulerColor = sketch.color(52, 73, 94); // 深蓝灰色 25 | const groundColor = sketch.color(236, 240, 241); // 浅灰色 26 | const trackColor = sketch.color(52, 73, 94); // 深蓝灰色 27 | const trainWindowColor = sketch.color(214, 234, 248); // 浅蓝色 28 | const trainWheelColor = sketch.color(44, 62, 80); // 深蓝黑色 29 | const formulaColor = sketch.color(52, 73, 94); // 深蓝灰色 30 | 31 | sketch.setup = function() { 32 | // 创建画布 33 | const canvas = sketch.createCanvas(container.offsetWidth, container.offsetHeight); 34 | canvas.parent('length-contraction-animation'); 35 | 36 | // 设置文本属性 37 | sketch.textAlign(sketch.CENTER, sketch.CENTER); 38 | sketch.textSize(14); 39 | 40 | // 初始化lastTime 41 | lastTime = sketch.millis() / 1000; 42 | 43 | // 初始化列车位置 44 | trainPosition = -50; 45 | pausePosition = trainPosition; 46 | 47 | // 添加点击事件监听器,确保点击动画区域也能触发动画 48 | canvas.mousePressed(function() { 49 | // 如果点击了动画区域,确保重新计算lastTime以避免时间跳跃 50 | lastTime = sketch.millis() / 1000; 51 | }); 52 | 53 | // 监听重置按钮 54 | const resetBtn = document.getElementById('reset'); 55 | if (resetBtn) { 56 | resetBtn.addEventListener('click', resetAnimation); 57 | } 58 | }; 59 | 60 | // 重置动画函数 61 | function resetAnimation() { 62 | time = 0; 63 | trainPosition = -50; 64 | pausePosition = trainPosition; 65 | lastTime = sketch.millis() / 1000; 66 | console.log("长度收缩动画已重置"); 67 | } 68 | 69 | sketch.draw = function() { 70 | // 获取当前参数 71 | const velocity = parseFloat(document.getElementById('velocity').value); 72 | const properLength = parseFloat(document.getElementById('proper-length').value); 73 | const gamma = 1 / Math.sqrt(1 - velocity * velocity); 74 | const contractedLength = properLength / gamma; 75 | 76 | // 获取当前的isPlaying状态 77 | const isPlayingNow = window.isPlayingState || false; 78 | 79 | // 检测播放状态变化 80 | if (isPlayingNow !== prevPlayState) { 81 | if (isPlayingNow) { 82 | // 从暂停到播放,更新lastTime以避免时间跳跃 83 | lastTime = sketch.millis() / 1000; 84 | } else { 85 | // 从播放到暂停,记录当前位置 86 | pausePosition = trainPosition; 87 | } 88 | prevPlayState = isPlayingNow; 89 | } 90 | 91 | // 更新时间和位置 92 | if (isPlayingNow) { 93 | const currentTime = sketch.millis() / 1000; 94 | const deltaTime = currentTime - lastTime; 95 | lastTime = currentTime; 96 | 97 | // 更新列车位置 98 | trainSpeed = velocity * 100; // 缩放速度以适应画布 99 | trainPosition += trainSpeed * deltaTime; 100 | 101 | // 循环动画 102 | if (trainPosition > sketch.width + properLength) { 103 | trainPosition = -properLength * 1.5; 104 | } 105 | } else { 106 | // 暂停状态,使用记录的暂停位置 107 | trainPosition = pausePosition; 108 | } 109 | 110 | // 绘制背景 - 使用渐变背景 111 | const bgGradientTop = sketch.color(240, 248, 255); // 爱丽丝蓝 112 | const bgGradientBottom = sketch.color(245, 245, 250); // 淡紫色 113 | for (let y = 0; y < sketch.height; y++) { 114 | const inter = sketch.map(y, 0, sketch.height, 0, 1); 115 | const c = sketch.lerpColor(bgGradientTop, bgGradientBottom, inter); 116 | sketch.stroke(c); 117 | sketch.line(0, y, sketch.width, y); 118 | } 119 | 120 | // 绘制标尺 121 | drawRuler(sketch, 0, sketch.height * 0.45, sketch.width, 20, rulerColor); 122 | 123 | // 绘制静止参考系标题 - 添加阴影效果 124 | sketch.fill(staticColor); 125 | sketch.noStroke(); 126 | sketch.textSize(20); 127 | sketch.textStyle(sketch.BOLD); 128 | sketch.text("静止参考系观察者", sketch.width * 0.2, 30); 129 | 130 | // 绘制运动参考系标题 - 添加阴影效果 131 | sketch.fill(movingColor); 132 | sketch.textSize(20); 133 | sketch.textStyle(sketch.BOLD); 134 | sketch.text("运动参考系观察者", sketch.width * 0.8, 30); 135 | sketch.textStyle(sketch.NORMAL); 136 | 137 | // 绘制观察者 - 使用更精细的人物图形 138 | drawObserver(sketch, sketch.width * 0.2, sketch.height * 0.2, staticColor); 139 | drawObserver(sketch, sketch.width * 0.8, sketch.height * 0.2, movingColor); 140 | 141 | // 绘制静止参考系中的列车 142 | const staticTrainX = sketch.width * 0.5 - properLength / 2; 143 | drawTrain(sketch, staticTrainX, sketch.height * 0.3, properLength, 30, staticColor, trainWindowColor, trainWheelColor, "静止长度: " + properLength.toFixed(1) + " m"); 144 | 145 | // 绘制运动参考系中的列车 146 | drawTrain(sketch, trainPosition, sketch.height * 0.6, contractedLength, 30, movingColor, trainWindowColor, trainWheelColor, "收缩长度: " + contractedLength.toFixed(1) + " m"); 147 | 148 | // 绘制地面和轨道 - 使用更美观的设计 149 | // 绘制地面 150 | sketch.fill(groundColor); 151 | sketch.noStroke(); 152 | sketch.rect(0, sketch.height * 0.75, sketch.width, sketch.height * 0.25); 153 | 154 | // 绘制轨道 155 | sketch.stroke(trackColor); 156 | sketch.strokeWeight(3); 157 | sketch.line(0, sketch.height * 0.75, sketch.width, sketch.height * 0.75); 158 | 159 | // 绘制轨道枕木 160 | sketch.strokeWeight(2); 161 | for (let x = 0; x < sketch.width; x += 30) { 162 | sketch.line(x, sketch.height * 0.75 - 5, x, sketch.height * 0.75 + 5); 163 | } 164 | 165 | // 绘制信息面板背景 166 | sketch.fill(255, 255, 255, 200); 167 | sketch.strokeWeight(1); 168 | sketch.stroke(200); 169 | sketch.rect(sketch.width * 0.5 - 150, sketch.height - 110, 300, 90, 10); 170 | 171 | // 绘制速度信息 172 | sketch.fill(formulaColor); 173 | sketch.textSize(16); 174 | sketch.text(`相对速度: ${velocity.toFixed(2)}c`, sketch.width * 0.5, sketch.height - 80); 175 | sketch.text(`洛伦兹因子 (γ): ${gamma.toFixed(3)}`, sketch.width * 0.5, sketch.height - 55); 176 | 177 | // 绘制公式 178 | sketch.fill(formulaColor); 179 | sketch.textSize(14); 180 | sketch.text("L' = L/γ = L×√(1-v²/c²)", sketch.width * 0.5, sketch.height - 30); 181 | 182 | // 绘制视线 - 使用更精细的虚线 183 | if (isPlayingNow) { 184 | drawViewLines(sketch, sketch.width * 0.2, sketch.height * 0.2, staticTrainX, staticTrainX + properLength, sketch.height * 0.3, staticColor); 185 | drawViewLines(sketch, sketch.width * 0.8, sketch.height * 0.2, trainPosition, trainPosition + contractedLength, sketch.height * 0.6, movingColor); 186 | } 187 | 188 | // 绘制信息框 - 显示公式和计算结果 189 | drawInfoBox(sketch, sketch.width - 200, 80, 180, 100, velocity, properLength, contractedLength, gamma); 190 | 191 | // 更新计算结果 192 | updateLengthContractionResults(velocity, properLength, gamma); 193 | }; 194 | 195 | // 绘制信息框 196 | function drawInfoBox(sketch, x, y, width, height, velocity, properLength, contractedLength, gamma) { 197 | // 绘制半透明背景 198 | sketch.fill(255, 255, 255, 220); 199 | sketch.stroke(200); 200 | sketch.strokeWeight(1); 201 | sketch.rect(x, y, width, height, 10); 202 | 203 | // 绘制标题 204 | sketch.fill(formulaColor); 205 | sketch.noStroke(); 206 | sketch.textAlign(sketch.LEFT, sketch.CENTER); 207 | sketch.textSize(14); 208 | sketch.textStyle(sketch.BOLD); 209 | sketch.text("计算详情:", x + 10, y + 20); 210 | sketch.textStyle(sketch.NORMAL); 211 | 212 | // 绘制计算过程 213 | sketch.textSize(12); 214 | sketch.text(`L' = L × √(1-v²/c²)`, x + 10, y + 45); 215 | sketch.text(`L' = ${properLength} × √(1-${velocity.toFixed(2)}²)`, x + 10, y + 65); 216 | sketch.text(`L' = ${properLength} × ${(1/gamma).toFixed(3)} = ${contractedLength.toFixed(2)}`, x + 10, y + 85); 217 | 218 | // 恢复文本对齐方式 219 | sketch.textAlign(sketch.CENTER, sketch.CENTER); 220 | } 221 | 222 | // 绘制观察者 - 更精细的人物图形 223 | function drawObserver(sketch, x, y, color) { 224 | // 绘制头部 225 | sketch.fill(color); 226 | sketch.stroke(50); 227 | sketch.strokeWeight(1.5); 228 | sketch.ellipse(x, y - 15, 22, 22); 229 | 230 | // 绘制眼睛 231 | sketch.fill(255); 232 | sketch.noStroke(); 233 | sketch.ellipse(x - 5, y - 16, 5, 5); 234 | sketch.ellipse(x + 5, y - 16, 5, 5); 235 | 236 | // 绘制瞳孔 237 | sketch.fill(0); 238 | sketch.ellipse(x - 5, y - 16, 2, 2); 239 | sketch.ellipse(x + 5, y - 16, 2, 2); 240 | 241 | // 绘制嘴巴 242 | sketch.noFill(); 243 | sketch.stroke(50); 244 | sketch.strokeWeight(1); 245 | sketch.arc(x, y - 10, 10, 6, 0, sketch.PI); 246 | 247 | // 绘制身体 248 | sketch.stroke(color); 249 | sketch.strokeWeight(3); 250 | sketch.line(x, y - 4, x, y + 15); 251 | 252 | // 绘制手臂 253 | sketch.line(x, y, x - 12, y + 8); 254 | sketch.line(x, y, x + 12, y + 8); 255 | 256 | // 绘制腿 257 | sketch.line(x, y + 15, x - 8, y + 35); 258 | sketch.line(x, y + 15, x + 8, y + 35); 259 | } 260 | 261 | // 绘制视线 - 更精细的虚线 262 | function drawViewLines(sketch, observerX, observerY, objectStartX, objectEndX, objectY, color) { 263 | sketch.stroke(color); 264 | sketch.strokeWeight(1.5); 265 | sketch.setLineDash([8, 4]); 266 | sketch.line(observerX, observerY, objectStartX, objectY); 267 | sketch.line(observerX, observerY, objectEndX, objectY); 268 | sketch.setLineDash([]); 269 | } 270 | 271 | // 绘制标尺 - 更美观的标尺 272 | function drawRuler(sketch, x, y, width, height, color) { 273 | const divisions = 20; 274 | const divisionWidth = width / divisions; 275 | 276 | // 绘制标尺背景 277 | sketch.fill(255, 255, 255, 200); 278 | sketch.stroke(color); 279 | sketch.strokeWeight(1); 280 | sketch.rect(x, y, width, height, 3); 281 | 282 | // 绘制刻度 283 | for (let i = 0; i <= divisions; i++) { 284 | const divX = x + i * divisionWidth; 285 | const divHeight = i % 5 === 0 ? height * 0.8 : height * 0.4; 286 | sketch.stroke(color); 287 | sketch.strokeWeight(i % 5 === 0 ? 1.5 : 1); 288 | sketch.line(divX, y, divX, y + divHeight); 289 | 290 | if (i % 5 === 0) { 291 | sketch.fill(color); 292 | sketch.noStroke(); 293 | sketch.textSize(10); 294 | sketch.text(i, divX, y + height + 10); 295 | } 296 | } 297 | } 298 | 299 | // 绘制列车函数 - 更精细的列车设计 300 | function drawTrain(sketch, x, y, length, height, color, windowColor, wheelColor, label) { 301 | // 绘制列车主体 302 | sketch.fill(color); 303 | sketch.stroke(50); 304 | sketch.strokeWeight(2); 305 | sketch.rect(x, y - height / 2, length, height, 5); 306 | 307 | // 绘制车窗 308 | const windowCount = Math.max(1, Math.floor(length / 30)); 309 | const windowSpacing = length / (windowCount + 1); 310 | 311 | sketch.fill(windowColor); 312 | sketch.strokeWeight(1); 313 | sketch.stroke(100); 314 | 315 | for (let i = 1; i <= windowCount; i++) { 316 | const windowX = x + i * windowSpacing - 10; 317 | const windowY = y - height / 4; 318 | sketch.rect(windowX, windowY, 20, height / 2, 3); 319 | 320 | // 绘制窗户内部细节 321 | sketch.line(windowX + 10, windowY, windowX + 10, windowY + height / 2); 322 | sketch.line(windowX, windowY + height / 4, windowX + 20, windowY + height / 4); 323 | } 324 | 325 | // 绘制车轮 326 | const wheelCount = Math.max(2, Math.floor(length / 40)); 327 | const wheelSpacing = length / (wheelCount - 1); 328 | 329 | for (let i = 0; i < wheelCount; i++) { 330 | const wheelX = x + i * wheelSpacing; 331 | const wheelY = y + height / 2 + 10; 332 | 333 | // 绘制轮毂 334 | sketch.fill(wheelColor); 335 | sketch.stroke(30); 336 | sketch.strokeWeight(1.5); 337 | sketch.ellipse(wheelX, wheelY, 20, 20); 338 | 339 | // 绘制轮辐 340 | sketch.stroke(100); 341 | sketch.strokeWeight(1); 342 | for (let j = 0; j < 8; j++) { 343 | const angle = j * sketch.PI / 4; 344 | sketch.line(wheelX, wheelY, 345 | wheelX + 8 * sketch.cos(angle), 346 | wheelY + 8 * sketch.sin(angle)); 347 | } 348 | 349 | // 绘制轮缘 350 | sketch.noFill(); 351 | sketch.stroke(30); 352 | sketch.strokeWeight(2); 353 | sketch.ellipse(wheelX, wheelY, 16, 16); 354 | } 355 | 356 | // 绘制标签 - 添加背景使文字更清晰 357 | sketch.fill(255, 255, 255, 180); 358 | sketch.stroke(color); 359 | sketch.strokeWeight(1); 360 | sketch.rect(x + length / 2 - 70, y - height / 2 - 25, 140, 20, 5); 361 | 362 | sketch.fill(color); 363 | sketch.noStroke(); 364 | sketch.textSize(14); 365 | sketch.text(label, x + length / 2, y - height / 2 - 15); 366 | 367 | // 绘制长度标记 - 更美观的箭头 368 | sketch.stroke(color); 369 | sketch.strokeWeight(2); 370 | sketch.line(x, y + height / 2 + 30, x + length, y + height / 2 + 30); 371 | 372 | // 绘制箭头 373 | sketch.fill(color); 374 | sketch.noStroke(); 375 | 376 | // 左箭头 377 | sketch.triangle( 378 | x, y + height / 2 + 30, 379 | x + 10, y + height / 2 + 25, 380 | x + 10, y + height / 2 + 35 381 | ); 382 | 383 | // 右箭头 384 | sketch.triangle( 385 | x + length, y + height / 2 + 30, 386 | x + length - 10, y + height / 2 + 25, 387 | x + length - 10, y + height / 2 + 35 388 | ); 389 | } 390 | 391 | // 设置虚线样式 392 | sketch.setLineDash = function(pattern) { 393 | sketch.drawingContext.setLineDash(pattern); 394 | }; 395 | 396 | // 窗口大小改变时调整画布大小 397 | sketch.windowResized = function() { 398 | const container = document.getElementById('length-contraction-animation'); 399 | sketch.resizeCanvas(container.offsetWidth, container.offsetHeight); 400 | }; 401 | }, container); // 直接将容器传递给p5构造函数 402 | } 403 | 404 | // 更新长度收缩计算结果 405 | function updateLengthContractionResults(velocity, properLength, gamma) { 406 | const contractedLength = properLength / gamma; 407 | document.getElementById('lorentz-factor-length').textContent = gamma.toFixed(3); 408 | document.getElementById('contracted-length').textContent = contractedLength.toFixed(2) + " 米"; 409 | } -------------------------------------------------------------------------------- /simulations/optics-modern/relativity/light-cone.js: -------------------------------------------------------------------------------- 1 | // 相对论可视化模拟 - 光锥与因果关系模块 2 | 3 | // 光锥可视化 4 | function setupLightConeSketch() { 5 | // 确保容器存在 6 | const container = document.getElementById('light-cone-animation'); 7 | console.log("光锥容器:", container); 8 | if (!container) { 9 | console.error('找不到光锥动画容器'); 10 | // 检查所有可能的容器ID 11 | console.log("检查所有可能的容器:"); 12 | console.log("light-cone-animation:", document.getElementById('light-cone-animation')); 13 | console.log("light-cone-content:", document.getElementById('light-cone-content')); 14 | 15 | // 如果容器不存在,可能是因为标签页尚未显示,尝试先显示标签页 16 | const lightConeTab = document.getElementById('tab-light-cone'); 17 | if (lightConeTab) { 18 | console.log("尝试点击光锥标签页"); 19 | lightConeTab.click(); 20 | 21 | // 延迟一点时间后再次尝试获取容器 22 | setTimeout(() => { 23 | const containerRetry = document.getElementById('light-cone-animation'); 24 | if (containerRetry) { 25 | console.log("成功获取光锥容器(重试)"); 26 | initLightConeSketch(containerRetry); 27 | } else { 28 | console.error("重试后仍然找不到光锥容器"); 29 | } 30 | }, 300); 31 | } 32 | return; 33 | } 34 | 35 | // 如果容器存在但不可见,可能会导致画布尺寸计算错误 36 | if (container.offsetWidth === 0 || container.offsetHeight === 0) { 37 | console.warn("光锥容器尺寸为零,可能是因为它不可见"); 38 | 39 | // 尝试先显示标签页 40 | const lightConeTab = document.getElementById('tab-light-cone'); 41 | if (lightConeTab) { 42 | console.log("尝试点击光锥标签页"); 43 | lightConeTab.click(); 44 | 45 | // 延迟一点时间后再次尝试初始化 46 | setTimeout(() => { 47 | if (container.offsetWidth > 0 && container.offsetHeight > 0) { 48 | console.log("容器现在可见,初始化光锥动画"); 49 | initLightConeSketch(container); 50 | } else { 51 | console.error("容器仍然不可见,无法初始化光锥动画"); 52 | } 53 | }, 300); 54 | } 55 | return; 56 | } 57 | 58 | // 正常初始化 59 | initLightConeSketch(container); 60 | } 61 | 62 | // 实际初始化光锥动画的函数 63 | function initLightConeSketch(container) { 64 | // 如果已经初始化过,则不再重复初始化 65 | if (lightConeSketch) { 66 | console.log("光锥动画已经初始化,跳过"); 67 | return; 68 | } 69 | 70 | console.log("开始初始化光锥动画,容器尺寸:", container.offsetWidth, container.offsetHeight); 71 | 72 | lightConeSketch = new p5((sketch) => { 73 | // 动画参数 74 | let time = 0; 75 | let lastTime = 0; 76 | let gridSize = 40; 77 | let originX, originY; 78 | let scale = 20; // 像素/米 79 | let rotationAngle = 0; 80 | let coneOpacity = 150; 81 | let prevPlayState = false; // 记录上一帧的播放状态 82 | let pauseTime = 0; // 记录暂停时的时间 83 | let pauseRotation = 0; // 记录暂停时的旋转角度 84 | let coneAngle = Math.PI/4; // 光锥角度,默认45度(光速为1) 85 | 86 | // 颜色 87 | const futureColor = sketch.color(94, 106, 210, coneOpacity); // 蓝色 88 | const pastColor = sketch.color(16, 185, 129, coneOpacity); // 绿色 89 | const lightlikeColor = sketch.color(249, 115, 22); // 橙色 90 | const spacelikeColor = sketch.color(220, 38, 38, coneOpacity); // 红色 91 | const timelikeColor = sketch.color(79, 70, 229, coneOpacity); // 紫色 92 | 93 | sketch.setup = function() { 94 | // 创建画布 95 | console.log("创建光锥画布, 容器尺寸:", container.offsetWidth, container.offsetHeight); 96 | const canvas = sketch.createCanvas(container.offsetWidth, container.offsetHeight); 97 | canvas.parent('light-cone-animation'); 98 | 99 | // 设置文本属性 100 | sketch.textAlign(sketch.CENTER, sketch.CENTER); 101 | sketch.textSize(14); 102 | 103 | // 初始化lastTime 104 | lastTime = sketch.millis() / 1000; 105 | 106 | // 设置坐标原点在画布中心 107 | originX = sketch.width / 2; 108 | originY = sketch.height / 2; 109 | 110 | // 添加点击事件监听器,确保点击动画区域也能触发动画 111 | canvas.mousePressed(function() { 112 | // 如果点击了动画区域,确保重新计算lastTime以避免时间跳跃 113 | lastTime = sketch.millis() / 1000; 114 | }); 115 | 116 | // 监听重置按钮 117 | const resetBtn = document.getElementById('reset'); 118 | if (resetBtn) { 119 | resetBtn.addEventListener('click', resetAnimation); 120 | } 121 | }; 122 | 123 | // 重置动画函数 124 | function resetAnimation() { 125 | time = 0; 126 | rotationAngle = 0; 127 | pauseTime = 0; 128 | pauseRotation = 0; 129 | lastTime = sketch.millis() / 1000; 130 | console.log("光锥动画已重置"); 131 | } 132 | 133 | sketch.draw = function() { 134 | // 获取当前参数 135 | const velocity = parseFloat(document.getElementById('velocity').value); 136 | const eventSeparation = parseFloat(document.getElementById('event-separation').value); 137 | const gamma = 1 / Math.sqrt(1 - velocity * velocity); 138 | 139 | // 计算事件间隔 140 | const deltaT = eventSeparation; 141 | const deltaX = eventSeparation * 0.5; 142 | const spacetimeInterval = deltaT * deltaT - deltaX * deltaX; 143 | 144 | // 确定事件关系 145 | let relation = "类时"; 146 | let relationColor = timelikeColor; 147 | if (spacetimeInterval < 0) { 148 | relation = "类空"; 149 | relationColor = spacelikeColor; 150 | } else if (Math.abs(spacetimeInterval) < 0.01) { 151 | relation = "类光"; 152 | relationColor = lightlikeColor; 153 | } 154 | 155 | // 获取当前的isPlaying状态 156 | const isPlayingNow = window.isPlayingState || false; 157 | 158 | // 检测播放状态变化 159 | if (isPlayingNow !== prevPlayState) { 160 | if (isPlayingNow) { 161 | // 从暂停到播放,更新lastTime以避免时间跳跃 162 | lastTime = sketch.millis() / 1000; 163 | } else { 164 | // 从播放到暂停,记录当前时间和旋转角度 165 | pauseTime = time; 166 | pauseRotation = rotationAngle; 167 | } 168 | prevPlayState = isPlayingNow; 169 | } 170 | 171 | // 更新时间和旋转角度 172 | if (isPlayingNow) { 173 | const currentTime = sketch.millis() / 1000; 174 | const deltaTime = currentTime - lastTime; 175 | lastTime = currentTime; 176 | time += deltaTime; 177 | rotationAngle += deltaTime * 0.5; 178 | } else { 179 | // 暂停状态,使用记录的暂停时间和旋转角度 180 | time = pauseTime; 181 | rotationAngle = pauseRotation; 182 | } 183 | 184 | // 绘制背景 185 | sketch.background(245, 245, 250); 186 | 187 | // 绘制网格和坐标轴 188 | drawGrid(sketch, originX, originY, sketch.width, sketch.height, gridSize); 189 | drawCoordinateSystem(sketch, originX, originY, sketch.color(70), "x", "t"); 190 | 191 | // 根据相对速度调整光锥角度(这是一个视觉效果,实际上光锥角度在所有参考系中都是45度) 192 | // 这里我们用一个视觉效果来展示相对速度对观察者感知的影响 193 | const observerEffect = Math.max(0.1, 1 - velocity * 0.5); // 视觉效果因子 194 | 195 | // 绘制光锥 196 | drawLightCone(sketch, originX, originY, scale, observerEffect); 197 | 198 | // 绘制事件点 199 | const event1X = originX; 200 | const event1Y = originY; 201 | drawEvent(sketch, event1X, event1Y, lightlikeColor, "事件A"); 202 | 203 | // 绘制第二个事件点 204 | const event2X = originX + deltaX * scale; 205 | const event2Y = originY - deltaT * scale; 206 | drawEvent(sketch, event2X, event2Y, lightlikeColor, "事件B"); 207 | 208 | // 绘制连接线 209 | sketch.stroke(relationColor); 210 | sketch.strokeWeight(2); 211 | sketch.line(event1X, event1Y, event2X, event2Y); 212 | 213 | // 绘制事件关系区域 214 | if (relation === "类时") { 215 | drawTimelikeRegion(sketch, originX, originY, scale, observerEffect); 216 | } else if (relation === "类空") { 217 | drawSpacelikeRegion(sketch, originX, originY, scale, observerEffect); 218 | } 219 | 220 | // 绘制信息 - 移到顶部,避免遮挡图像 221 | sketch.fill(70); 222 | sketch.textSize(16); 223 | sketch.text(`事件间隔: ${eventSeparation.toFixed(2)}`, sketch.width * 0.5, 30); 224 | 225 | // 绘制公式 - 移到底部,避免遮挡图像 226 | sketch.fill(70); 227 | sketch.textSize(14); 228 | sketch.text("s² = c²Δt² - Δx²", sketch.width * 0.2, sketch.height - 20); 229 | 230 | // 绘制结果信息 - 移到右下角,避免遮挡图像 231 | sketch.fill(70); 232 | sketch.textSize(16); 233 | sketch.text(`时空间隔 (s²): ${spacetimeInterval.toFixed(2)}`, sketch.width * 0.8, sketch.height - 40); 234 | sketch.text(`事件关系: ${relation}`, sketch.width * 0.8, sketch.height - 20); 235 | 236 | // 更新计算结果 237 | updateLightConeResults(spacetimeInterval, relation); 238 | }; 239 | 240 | // 绘制网格 241 | function drawGrid(sketch, originX, originY, width, height, gridSize) { 242 | sketch.stroke(220); 243 | sketch.strokeWeight(1); 244 | 245 | // 绘制垂直线 246 | for (let x = originX % gridSize; x < width; x += gridSize) { 247 | sketch.line(x, 0, x, height); 248 | } 249 | 250 | // 绘制水平线 251 | for (let y = originY % gridSize; y < height; y += gridSize) { 252 | sketch.line(0, y, width, y); 253 | } 254 | } 255 | 256 | // 绘制坐标系 257 | function drawCoordinateSystem(sketch, originX, originY, color, xLabel, tLabel) { 258 | sketch.stroke(color); 259 | sketch.strokeWeight(2); 260 | 261 | // x轴 262 | sketch.line(0, originY, sketch.width, originY); 263 | 264 | // t轴 265 | sketch.line(originX, sketch.height, originX, 0); 266 | 267 | // x轴箭头 268 | sketch.fill(color); 269 | sketch.noStroke(); 270 | sketch.triangle( 271 | sketch.width - 10, originY, 272 | sketch.width - 20, originY - 5, 273 | sketch.width - 20, originY + 5 274 | ); 275 | 276 | // t轴箭头 277 | sketch.triangle( 278 | originX, 10, 279 | originX - 5, 20, 280 | originX + 5, 20 281 | ); 282 | 283 | // 坐标轴标签 284 | sketch.textSize(16); 285 | sketch.text(xLabel, sketch.width - 20, originY + 20); 286 | sketch.text(tLabel, originX + 20, 20); 287 | } 288 | 289 | // 绘制光锥 290 | function drawLightCone(sketch, originX, originY, scale, observerEffect) { 291 | // 计算光锥边界点 292 | const coneWidth = scale * 5 * observerEffect; 293 | const coneHeight = scale * 5; 294 | 295 | // 绘制未来光锥 296 | sketch.fill(futureColor); 297 | sketch.noStroke(); 298 | sketch.beginShape(); 299 | sketch.vertex(originX, originY); 300 | sketch.vertex(originX - coneWidth, originY - coneHeight); 301 | sketch.vertex(originX + coneWidth, originY - coneHeight); 302 | sketch.endShape(sketch.CLOSE); 303 | 304 | // 绘制过去光锥 305 | sketch.fill(pastColor); 306 | sketch.noStroke(); 307 | sketch.beginShape(); 308 | sketch.vertex(originX, originY); 309 | sketch.vertex(originX - coneWidth, originY + coneHeight); 310 | sketch.vertex(originX + coneWidth, originY + coneHeight); 311 | sketch.endShape(sketch.CLOSE); 312 | 313 | // 绘制光锥边界线 314 | sketch.stroke(lightlikeColor); 315 | sketch.strokeWeight(2); 316 | sketch.line(originX, originY, originX - coneWidth, originY - coneHeight); 317 | sketch.line(originX, originY, originX + coneWidth, originY - coneHeight); 318 | sketch.line(originX, originY, originX - coneWidth, originY + coneHeight); 319 | sketch.line(originX, originY, originX + coneWidth, originY + coneHeight); 320 | } 321 | 322 | // 绘制类时区域 323 | function drawTimelikeRegion(sketch, originX, originY, scale, observerEffect) { 324 | sketch.fill(timelikeColor); 325 | sketch.noStroke(); 326 | 327 | // 计算类时区域边界 328 | const timelikeWidth = scale * 3 * observerEffect; 329 | const timelikeHeight = scale * 5; 330 | 331 | // 绘制未来类时区域 332 | sketch.beginShape(); 333 | sketch.vertex(originX, originY); 334 | sketch.vertex(originX - timelikeWidth, originY - timelikeHeight); 335 | sketch.vertex(originX + timelikeWidth, originY - timelikeHeight); 336 | sketch.endShape(sketch.CLOSE); 337 | 338 | // 绘制过去类时区域 339 | sketch.beginShape(); 340 | sketch.vertex(originX, originY); 341 | sketch.vertex(originX - timelikeWidth, originY + timelikeHeight); 342 | sketch.vertex(originX + timelikeWidth, originY + timelikeHeight); 343 | sketch.endShape(sketch.CLOSE); 344 | } 345 | 346 | // 绘制类空区域 347 | function drawSpacelikeRegion(sketch, originX, originY, scale, observerEffect) { 348 | sketch.fill(spacelikeColor); 349 | sketch.noStroke(); 350 | 351 | // 计算类空区域边界 352 | const spacelikeWidth = scale * 5; 353 | const spacelikeHeight = scale * 3 * observerEffect; 354 | 355 | // 绘制右侧类空区域 356 | sketch.beginShape(); 357 | sketch.vertex(originX, originY); 358 | sketch.vertex(originX + spacelikeWidth, originY - spacelikeHeight); 359 | sketch.vertex(originX + spacelikeWidth, originY + spacelikeHeight); 360 | sketch.endShape(sketch.CLOSE); 361 | 362 | // 绘制左侧类空区域 363 | sketch.beginShape(); 364 | sketch.vertex(originX, originY); 365 | sketch.vertex(originX - spacelikeWidth, originY - spacelikeHeight); 366 | sketch.vertex(originX - spacelikeWidth, originY + spacelikeHeight); 367 | sketch.endShape(sketch.CLOSE); 368 | } 369 | 370 | // 绘制事件点 371 | function drawEvent(sketch, x, y, color, label) { 372 | sketch.fill(color); 373 | sketch.noStroke(); 374 | sketch.ellipse(x, y, 10, 10); 375 | 376 | sketch.textSize(14); 377 | sketch.text(label, x, y - 15); 378 | } 379 | 380 | // 设置虚线样式 381 | sketch.setLineDash = function(pattern) { 382 | sketch.drawingContext.setLineDash(pattern); 383 | }; 384 | 385 | // 窗口大小改变时调整画布大小 386 | sketch.windowResized = function() { 387 | const container = document.getElementById('light-cone-animation'); 388 | if (container) { 389 | console.log("调整光锥画布大小:", container.offsetWidth, container.offsetHeight); 390 | sketch.resizeCanvas(container.offsetWidth, container.offsetHeight); 391 | 392 | // 重新设置坐标原点 393 | originX = sketch.width / 2; 394 | originY = sketch.height / 2; 395 | } 396 | }; 397 | }, container); // 直接将容器传递给p5构造函数 398 | 399 | console.log("光锥动画初始化完成"); 400 | } 401 | 402 | // 更新光锥计算结果 403 | function updateLightConeResults(spacetimeInterval, relation) { 404 | document.getElementById('spacetime-interval').textContent = spacetimeInterval.toFixed(2); 405 | document.getElementById('event-relation').textContent = relation; 406 | } -------------------------------------------------------------------------------- /simulations/optics-modern/relativity/lorentz-transform.js: -------------------------------------------------------------------------------- 1 | // 相对论可视化模拟 - 洛伦兹变换模块 2 | 3 | // 洛伦兹变换可视化 4 | function setupLorentzSketch() { 5 | // 确保容器存在 6 | const container = document.getElementById('lorentz-animation'); 7 | console.log("洛伦兹变换容器:", container); 8 | if (!container) { 9 | console.error('找不到洛伦兹变换动画容器'); 10 | // 检查所有可能的容器ID 11 | console.log("检查所有可能的容器:"); 12 | console.log("lorentz-animation:", document.getElementById('lorentz-animation')); 13 | console.log("lorentz-content:", document.getElementById('lorentz-content')); 14 | 15 | // 如果容器不存在,可能是因为标签页尚未显示,尝试先显示标签页 16 | const lorentzTab = document.getElementById('tab-lorentz'); 17 | if (lorentzTab) { 18 | console.log("尝试点击洛伦兹变换标签页"); 19 | lorentzTab.click(); 20 | 21 | // 延迟一点时间后再次尝试获取容器 22 | setTimeout(() => { 23 | const containerRetry = document.getElementById('lorentz-animation'); 24 | if (containerRetry) { 25 | console.log("成功获取洛伦兹变换容器(重试)"); 26 | initLorentzSketch(containerRetry); 27 | } else { 28 | console.error("重试后仍然找不到洛伦兹变换容器"); 29 | } 30 | }, 300); 31 | } 32 | return; 33 | } 34 | 35 | // 如果容器存在但不可见,可能会导致画布尺寸计算错误 36 | if (container.offsetWidth === 0 || container.offsetHeight === 0) { 37 | console.warn("洛伦兹变换容器尺寸为零,可能是因为它不可见"); 38 | 39 | // 尝试先显示标签页 40 | const lorentzTab = document.getElementById('tab-lorentz'); 41 | if (lorentzTab) { 42 | console.log("尝试点击洛伦兹变换标签页"); 43 | lorentzTab.click(); 44 | 45 | // 延迟一点时间后再次尝试初始化 46 | setTimeout(() => { 47 | if (container.offsetWidth > 0 && container.offsetHeight > 0) { 48 | console.log("容器现在可见,初始化洛伦兹变换动画"); 49 | initLorentzSketch(container); 50 | } else { 51 | console.error("容器仍然不可见,无法初始化洛伦兹变换动画"); 52 | } 53 | }, 300); 54 | } 55 | return; 56 | } 57 | 58 | // 正常初始化 59 | initLorentzSketch(container); 60 | } 61 | 62 | // 实际初始化洛伦兹变换动画的函数 63 | function initLorentzSketch(container) { 64 | // 如果已经初始化过,则不再重复初始化 65 | if (lorentzSketch) { 66 | console.log("洛伦兹变换动画已经初始化,跳过"); 67 | return; 68 | } 69 | 70 | console.log("开始初始化洛伦兹变换动画,容器尺寸:", container.offsetWidth, container.offsetHeight); 71 | 72 | lorentzSketch = new p5((sketch) => { 73 | // 动画参数 74 | let time = 0; 75 | let lastTime = 0; 76 | let gridSize = 40; 77 | let originX, originY; 78 | let scale = 20; // 像素/米 79 | let prevPlayState = false; // 记录上一帧的播放状态 80 | let pauseTime = 0; // 记录暂停时的时间 81 | 82 | // 参考系颜色 83 | const staticColor = sketch.color(94, 106, 210); // 蓝色 84 | const movingColor = sketch.color(16, 185, 129); // 绿色 85 | const eventColor = sketch.color(249, 115, 22); // 橙色 86 | 87 | sketch.setup = function() { 88 | // 创建画布 89 | console.log("创建洛伦兹变换画布, 容器尺寸:", container.offsetWidth, container.offsetHeight); 90 | const canvas = sketch.createCanvas(container.offsetWidth, container.offsetHeight); 91 | canvas.parent('lorentz-animation'); 92 | 93 | // 设置文本属性 94 | sketch.textAlign(sketch.CENTER, sketch.CENTER); 95 | sketch.textSize(14); 96 | 97 | // 初始化lastTime 98 | lastTime = sketch.millis() / 1000; 99 | 100 | // 设置坐标原点在画布中心 101 | originX = sketch.width / 2; 102 | originY = sketch.height / 2; 103 | 104 | // 添加点击事件监听器,确保点击动画区域也能触发动画 105 | canvas.mousePressed(function() { 106 | // 如果点击了动画区域,确保重新计算lastTime以避免时间跳跃 107 | lastTime = sketch.millis() / 1000; 108 | }); 109 | 110 | // 监听重置按钮 111 | const resetBtn = document.getElementById('reset'); 112 | if (resetBtn) { 113 | resetBtn.addEventListener('click', resetAnimation); 114 | } 115 | }; 116 | 117 | // 重置动画函数 118 | function resetAnimation() { 119 | time = 0; 120 | pauseTime = 0; 121 | lastTime = sketch.millis() / 1000; 122 | console.log("洛伦兹变换动画已重置"); 123 | } 124 | 125 | sketch.draw = function() { 126 | // 获取当前参数 127 | const velocity = parseFloat(document.getElementById('velocity').value); 128 | const eventTime = parseFloat(document.getElementById('event-time').value); 129 | const eventPosition = parseFloat(document.getElementById('event-position').value); 130 | const gamma = 1 / Math.sqrt(1 - velocity * velocity); 131 | 132 | // 计算洛伦兹变换后的坐标 133 | const transformedTime = gamma * (eventTime - velocity * eventPosition); 134 | const transformedPosition = gamma * (eventPosition - velocity * eventTime); 135 | 136 | // 获取当前的isPlaying状态 137 | const isPlayingNow = window.isPlayingState || false; 138 | 139 | // 检测播放状态变化 140 | if (isPlayingNow !== prevPlayState) { 141 | if (isPlayingNow) { 142 | // 从暂停到播放,更新lastTime以避免时间跳跃 143 | lastTime = sketch.millis() / 1000; 144 | } else { 145 | // 从播放到暂停,记录当前时间 146 | pauseTime = time; 147 | } 148 | prevPlayState = isPlayingNow; 149 | } 150 | 151 | // 更新时间 152 | if (isPlayingNow) { 153 | const currentTime = sketch.millis() / 1000; 154 | const deltaTime = currentTime - lastTime; 155 | lastTime = currentTime; 156 | time += deltaTime; 157 | } else { 158 | // 暂停状态,使用记录的暂停时间 159 | time = pauseTime; 160 | } 161 | 162 | // 绘制背景 163 | sketch.background(245, 245, 250); 164 | 165 | // 绘制网格和坐标轴 166 | drawGrid(sketch, originX, originY, sketch.width, sketch.height, gridSize); 167 | 168 | // 绘制静止参考系标题 169 | sketch.fill(staticColor); 170 | sketch.noStroke(); 171 | sketch.textSize(18); 172 | sketch.text("静止参考系 (S)", sketch.width * 0.25, 30); 173 | 174 | // 绘制运动参考系标题 175 | sketch.fill(movingColor); 176 | sketch.textSize(18); 177 | sketch.text("运动参考系 (S')", sketch.width * 0.75, 30); 178 | 179 | // 绘制静止参考系坐标轴 180 | drawCoordinateSystem(sketch, originX, originY, staticColor, "x", "t"); 181 | 182 | // 绘制运动参考系坐标轴(倾斜的) 183 | drawTransformedCoordinateSystem(sketch, originX, originY, movingColor, velocity, "x'", "t'"); 184 | 185 | // 绘制事件点 186 | const eventX = originX + eventPosition * scale; 187 | const eventY = originY - eventTime * scale; 188 | drawEvent(sketch, eventX, eventY, eventColor, "事件"); 189 | 190 | // 绘制变换后的事件点 191 | const transformedX = originX + transformedPosition * scale; 192 | const transformedY = originY - transformedTime * scale; 193 | drawEvent(sketch, transformedX, transformedY, eventColor, "变换后事件"); 194 | 195 | // 绘制连接线 196 | sketch.stroke(eventColor); 197 | sketch.strokeWeight(1); 198 | sketch.setLineDash([5, 5]); 199 | sketch.line(eventX, eventY, transformedX, transformedY); 200 | sketch.setLineDash([]); 201 | 202 | // 绘制速度信息 203 | sketch.fill(70); 204 | sketch.textSize(16); 205 | sketch.text(`相对速度: ${velocity.toFixed(2)}c`, sketch.width * 0.5, sketch.height - 60); 206 | sketch.text(`洛伦兹因子 (γ): ${gamma.toFixed(3)}`, sketch.width * 0.5, sketch.height - 30); 207 | 208 | // 绘制公式 209 | sketch.fill(70); 210 | sketch.textSize(14); 211 | sketch.text("t' = γ(t - vx/c²)", sketch.width * 0.25, sketch.height - 90); 212 | sketch.text("x' = γ(x - vt)", sketch.width * 0.75, sketch.height - 90); 213 | 214 | // 更新计算结果 215 | updateLorentzResults(eventTime, eventPosition, transformedTime, transformedPosition); 216 | }; 217 | 218 | // 绘制网格 219 | function drawGrid(sketch, originX, originY, width, height, gridSize) { 220 | sketch.stroke(220); 221 | sketch.strokeWeight(1); 222 | 223 | // 绘制垂直线 224 | for (let x = originX % gridSize; x < width; x += gridSize) { 225 | sketch.line(x, 0, x, height); 226 | } 227 | 228 | // 绘制水平线 229 | for (let y = originY % gridSize; y < height; y += gridSize) { 230 | sketch.line(0, y, width, y); 231 | } 232 | } 233 | 234 | // 绘制坐标系 235 | function drawCoordinateSystem(sketch, originX, originY, color, xLabel, tLabel) { 236 | sketch.stroke(color); 237 | sketch.strokeWeight(2); 238 | 239 | // x轴 240 | sketch.line(0, originY, sketch.width, originY); 241 | 242 | // t轴 243 | sketch.line(originX, sketch.height, originX, 0); 244 | 245 | // x轴箭头 246 | sketch.fill(color); 247 | sketch.noStroke(); 248 | sketch.triangle( 249 | sketch.width - 10, originY, 250 | sketch.width - 20, originY - 5, 251 | sketch.width - 20, originY + 5 252 | ); 253 | 254 | // t轴箭头 255 | sketch.triangle( 256 | originX, 10, 257 | originX - 5, 20, 258 | originX + 5, 20 259 | ); 260 | 261 | // 坐标轴标签 262 | sketch.textSize(16); 263 | sketch.text(xLabel, sketch.width - 20, originY + 20); 264 | sketch.text(tLabel, originX + 20, 20); 265 | } 266 | 267 | // 绘制变换后的坐标系 268 | function drawTransformedCoordinateSystem(sketch, originX, originY, color, velocity, xLabel, tLabel) { 269 | const gamma = 1 / Math.sqrt(1 - velocity * velocity); 270 | const angle = Math.atan(velocity); 271 | const tAngle = Math.atan(velocity); 272 | 273 | sketch.stroke(color); 274 | sketch.strokeWeight(2); 275 | 276 | // 变换后的x'轴 277 | sketch.push(); 278 | sketch.translate(originX, originY); 279 | sketch.rotate(tAngle); 280 | sketch.line(-sketch.width, 0, sketch.width, 0); 281 | 282 | // x'轴箭头 283 | sketch.fill(color); 284 | sketch.noStroke(); 285 | sketch.triangle( 286 | sketch.width - 10, 0, 287 | sketch.width - 20, -5, 288 | sketch.width - 20, 5 289 | ); 290 | 291 | // x'轴标签 292 | sketch.textSize(16); 293 | sketch.text(xLabel, sketch.width - 20, 20); 294 | sketch.pop(); 295 | 296 | // 变换后的t'轴 297 | sketch.push(); 298 | sketch.translate(originX, originY); 299 | sketch.rotate(-angle); 300 | sketch.line(0, sketch.height, 0, -sketch.height); 301 | 302 | // t'轴箭头 303 | sketch.fill(color); 304 | sketch.noStroke(); 305 | sketch.triangle( 306 | 0, -sketch.height + 10, 307 | -5, -sketch.height + 20, 308 | 5, -sketch.height + 20 309 | ); 310 | 311 | // t'轴标签 312 | sketch.textSize(16); 313 | sketch.text(tLabel, 20, -sketch.height + 30); 314 | sketch.pop(); 315 | } 316 | 317 | // 绘制事件点 318 | function drawEvent(sketch, x, y, color, label) { 319 | sketch.fill(color); 320 | sketch.noStroke(); 321 | sketch.ellipse(x, y, 10, 10); 322 | 323 | sketch.textSize(14); 324 | sketch.text(label, x, y - 15); 325 | } 326 | 327 | // 设置虚线样式 328 | sketch.setLineDash = function(pattern) { 329 | sketch.drawingContext.setLineDash(pattern); 330 | }; 331 | 332 | // 窗口大小改变时调整画布大小 333 | sketch.windowResized = function() { 334 | const container = document.getElementById('lorentz-animation'); 335 | if (container) { 336 | console.log("调整洛伦兹变换画布大小:", container.offsetWidth, container.offsetHeight); 337 | sketch.resizeCanvas(container.offsetWidth, container.offsetHeight); 338 | 339 | // 重新设置坐标原点 340 | originX = sketch.width / 2; 341 | originY = sketch.height / 2; 342 | } 343 | }; 344 | }, container); // 直接将容器传递给p5构造函数 345 | 346 | console.log("洛伦兹变换动画初始化完成"); 347 | } 348 | 349 | // 更新洛伦兹变换计算结果 350 | function updateLorentzResults(eventTime, eventPosition, transformedTime, transformedPosition) { 351 | document.getElementById('transformed-time').textContent = transformedTime.toFixed(2) + " 秒"; 352 | document.getElementById('transformed-position').textContent = transformedPosition.toFixed(2) + " 米"; 353 | } -------------------------------------------------------------------------------- /simulations/optics-modern/relativity/relativity-main.js: -------------------------------------------------------------------------------- 1 | // 相对论可视化模拟 - 主JavaScript文件 2 | // 负责初始化和公共功能 3 | 4 | // 全局变量 5 | // 不定义isPlaying,而是在需要时从window对象获取 6 | let timeDilationSketch; 7 | let lengthContractionSketch; 8 | let lorentzSketch; 9 | let lightConeSketch; 10 | let isInitialized = false; // 添加初始化标志 11 | 12 | // 绘制参考系 13 | function drawReferenceFrame(sketch, x, y, velocity) { 14 | const frameWidth = 120; 15 | const frameHeight = 40; 16 | 17 | // 绘制参考系框架 18 | sketch.stroke(70); 19 | sketch.strokeWeight(2); 20 | sketch.fill(255); 21 | sketch.rect(x - frameWidth / 2, y - frameHeight / 2, frameWidth, frameHeight, 5); 22 | 23 | // 绘制速度信息 24 | sketch.fill(70); 25 | sketch.noStroke(); 26 | sketch.textSize(14); 27 | if (velocity === 0) { 28 | sketch.text("v = 0", x, y); 29 | } else { 30 | sketch.text(`v = ${velocity.toFixed(2)}c`, x, y); 31 | 32 | // 绘制速度箭头 33 | sketch.stroke(94, 106, 210); 34 | sketch.strokeWeight(2); 35 | sketch.line(x - 30, y + frameHeight / 2 + 10, x + 30, y + frameHeight / 2 + 10); 36 | sketch.fill(94, 106, 210); 37 | sketch.noStroke(); 38 | sketch.triangle( 39 | x + 30, y + frameHeight / 2 + 10, 40 | x + 20, y + frameHeight / 2 + 5, 41 | x + 20, y + frameHeight / 2 + 15 42 | ); 43 | } 44 | } 45 | 46 | // 初始化所有可视化 47 | function initVisualizations() { 48 | if (isInitialized) return; // 如果已经初始化,则直接返回 49 | 50 | console.log("初始化可视化..."); 51 | 52 | // 初始化时间膨胀可视化 53 | if (typeof setupTimeDilationSketch === 'function') { 54 | console.log("初始化时间膨胀可视化..."); 55 | setupTimeDilationSketch(); 56 | console.log("时间膨胀可视化初始化完成"); 57 | } else { 58 | console.error("找不到setupTimeDilationSketch函数"); 59 | } 60 | 61 | // 初始化长度收缩可视化 62 | if (typeof setupLengthContractionSketch === 'function') { 63 | console.log("初始化长度收缩可视化..."); 64 | setupLengthContractionSketch(); 65 | console.log("长度收缩可视化初始化完成"); 66 | } else { 67 | console.error("找不到setupLengthContractionSketch函数"); 68 | } 69 | 70 | // 初始化洛伦兹变换可视化 71 | if (typeof setupLorentzSketch === 'function') { 72 | console.log("初始化洛伦兹变换可视化..."); 73 | setupLorentzSketch(); 74 | console.log("洛伦兹变换可视化初始化完成"); 75 | } else { 76 | console.error("找不到setupLorentzSketch函数"); 77 | } 78 | 79 | // 初始化光锥可视化 80 | if (typeof setupLightConeSketch === 'function') { 81 | console.log("初始化光锥可视化..."); 82 | setupLightConeSketch(); 83 | console.log("光锥可视化初始化完成"); 84 | } else { 85 | console.error("找不到setupLightConeSketch函数"); 86 | } 87 | 88 | isInitialized = true; // 标记为已初始化 89 | console.log("所有可视化初始化完成"); 90 | } 91 | 92 | // 页面加载完成后初始化 93 | document.addEventListener('DOMContentLoaded', function() { 94 | console.log("DOM加载完成,准备初始化可视化..."); 95 | 96 | // 初始化全局播放状态 97 | window.isPlayingState = false; 98 | 99 | // 监听播放/暂停按钮的点击事件,将isPlaying状态存储到window对象中 100 | const playPauseBtn = document.getElementById('play-pause'); 101 | if (playPauseBtn) { 102 | playPauseBtn.addEventListener('click', function() { 103 | // 更新播放状态 104 | window.isPlayingState = !window.isPlayingState; 105 | console.log("播放状态更改为:", window.isPlayingState); 106 | 107 | // 更新按钮文本 108 | if (window.isPlayingState) { 109 | this.innerHTML = '暂停'; 110 | } else { 111 | this.innerHTML = '播放'; 112 | } 113 | }); 114 | } else { 115 | console.error("找不到播放/暂停按钮"); 116 | } 117 | 118 | // 监听重置按钮的点击事件 119 | const resetBtn = document.getElementById('reset'); 120 | if (resetBtn) { 121 | resetBtn.addEventListener('click', function() { 122 | // 重置播放状态 123 | window.isPlayingState = false; 124 | if (playPauseBtn) { 125 | playPauseBtn.innerHTML = '播放'; 126 | } 127 | 128 | // 各个动画模块中已经添加了重置函数,这里只需要处理全局状态 129 | console.log("全局重置已触发"); 130 | }); 131 | } else { 132 | console.error("找不到重置按钮"); 133 | } 134 | 135 | // 等待所有资源加载完成后再初始化可视化 136 | if (document.readyState === 'complete') { 137 | console.log("文档已完全加载,立即初始化可视化"); 138 | setTimeout(initVisualizations, 500); // 延迟初始化,确保DOM完全渲染 139 | } else { 140 | console.log("文档尚未完全加载,等待load事件"); 141 | window.addEventListener('load', function() { 142 | console.log("window.load事件触发,开始初始化可视化"); 143 | setTimeout(initVisualizations, 500); // 延迟初始化,确保DOM完全渲染 144 | }); 145 | } 146 | 147 | // 添加标签切换事件监听器 148 | const tabButtons = document.querySelectorAll('.tab-btn'); 149 | tabButtons.forEach(button => { 150 | button.addEventListener('click', function() { 151 | const tabId = this.id.replace('tab-', ''); 152 | console.log(`标签切换到: ${tabId}`); 153 | 154 | // 延迟一点时间后检查相应的动画是否已初始化 155 | setTimeout(() => { 156 | // 根据当前标签页重新初始化相应的动画 157 | if (tabId === 'time-dilation' && typeof setupTimeDilationSketch === 'function' && !timeDilationSketch) { 158 | console.log("初始化时间膨胀动画"); 159 | setupTimeDilationSketch(); 160 | } else if (tabId === 'length-contraction' && typeof setupLengthContractionSketch === 'function' && !lengthContractionSketch) { 161 | console.log("初始化长度收缩动画"); 162 | setupLengthContractionSketch(); 163 | } else if (tabId === 'lorentz' && typeof setupLorentzSketch === 'function' && !lorentzSketch) { 164 | console.log("初始化洛伦兹变换动画"); 165 | setupLorentzSketch(); 166 | } else if (tabId === 'light-cone' && typeof setupLightConeSketch === 'function' && !lightConeSketch) { 167 | console.log("初始化光锥动画"); 168 | setupLightConeSketch(); 169 | } 170 | }, 200); 171 | }); 172 | }); 173 | }); 174 | 175 | // 不再需要额外的window.load事件监听器,因为我们已经在DOMContentLoaded中处理了 -------------------------------------------------------------------------------- /simulations/optics-modern/relativity/time-dilation.js: -------------------------------------------------------------------------------- 1 | // 相对论可视化模拟 - 时间膨胀模块 2 | 3 | // 时钟类 4 | class Clock { 5 | constructor(x, y, radius, label) { 6 | this.x = x; 7 | this.y = y; 8 | this.radius = radius; 9 | this.label = label; 10 | this.angle = -Math.PI / 2; // 初始指向12点钟方向 11 | this.progress = 0; 12 | } 13 | 14 | update(time, maxTime) { 15 | // 更新时钟角度和进度 16 | this.angle = -Math.PI / 2 + (time / maxTime) * Math.PI * 2; 17 | this.progress = time / maxTime; 18 | } 19 | 20 | display(sketch) { 21 | // 绘制时钟外圈 22 | sketch.stroke(70); 23 | sketch.strokeWeight(2); 24 | sketch.fill(255); 25 | sketch.ellipse(this.x, this.y, this.radius * 2); 26 | 27 | // 绘制刻度 28 | for (let i = 0; i < 12; i++) { 29 | const angle = i * Math.PI / 6 - Math.PI / 2; 30 | const x1 = this.x + (this.radius - 10) * Math.cos(angle); 31 | const y1 = this.y + (this.radius - 10) * Math.sin(angle); 32 | const x2 = this.x + this.radius * Math.cos(angle); 33 | const y2 = this.y + this.radius * Math.sin(angle); 34 | 35 | sketch.stroke(70); 36 | sketch.strokeWeight(i % 3 === 0 ? 3 : 1); 37 | sketch.line(x1, y1, x2, y2); 38 | } 39 | 40 | // 绘制进度弧 41 | sketch.noStroke(); 42 | sketch.fill(94, 106, 210, 100); 43 | sketch.arc(this.x, this.y, this.radius * 1.8, this.radius * 1.8, -Math.PI / 2, this.angle, sketch.PIE); 44 | 45 | // 绘制指针 46 | sketch.stroke(30); 47 | sketch.strokeWeight(3); 48 | sketch.line( 49 | this.x, 50 | this.y, 51 | this.x + this.radius * 0.8 * Math.cos(this.angle), 52 | this.y + this.radius * 0.8 * Math.sin(this.angle) 53 | ); 54 | 55 | // 绘制中心点 56 | sketch.fill(30); 57 | sketch.noStroke(); 58 | sketch.ellipse(this.x, this.y, 8); 59 | 60 | // 绘制标签 61 | sketch.fill(70); 62 | sketch.textSize(16); 63 | sketch.text(this.label, this.x, this.y - this.radius - 20); 64 | } 65 | } 66 | 67 | // 时间膨胀可视化 68 | function setupTimeDilationSketch() { 69 | // 确保容器存在 70 | const container = document.getElementById('time-dilation-animation'); 71 | if (!container) { 72 | console.error('找不到时间膨胀动画容器'); 73 | return; 74 | } 75 | 76 | timeDilationSketch = new p5((sketch) => { 77 | // 动画参数 78 | let staticClock, movingClock; 79 | let time = 0; 80 | let lastTime = 0; 81 | let prevPlayState = false; // 记录上一帧的播放状态 82 | let pauseTime = 0; // 记录暂停时的时间 83 | 84 | sketch.setup = function() { 85 | // 创建画布 86 | const canvas = sketch.createCanvas(container.offsetWidth, container.offsetHeight); 87 | canvas.parent('time-dilation-animation'); 88 | 89 | // 初始化时钟对象 90 | staticClock = new Clock(sketch.width * 0.25, sketch.height * 0.5, 60, "静止参考系"); 91 | movingClock = new Clock(sketch.width * 0.75, sketch.height * 0.5, 60, "运动参考系"); 92 | 93 | // 设置文本属性 94 | sketch.textAlign(sketch.CENTER, sketch.CENTER); 95 | sketch.textSize(14); 96 | 97 | // 初始化lastTime 98 | lastTime = sketch.millis() / 1000; 99 | 100 | // 添加点击事件监听器,确保点击动画区域也能触发动画 101 | canvas.mousePressed(function() { 102 | // 如果点击了动画区域,确保重新计算lastTime以避免时间跳跃 103 | lastTime = sketch.millis() / 1000; 104 | }); 105 | 106 | // 监听重置按钮 107 | const resetBtn = document.getElementById('reset'); 108 | if (resetBtn) { 109 | resetBtn.addEventListener('click', resetAnimation); 110 | } 111 | }; 112 | 113 | // 重置动画函数 114 | function resetAnimation() { 115 | time = 0; 116 | pauseTime = 0; 117 | lastTime = sketch.millis() / 1000; 118 | 119 | // 重置时钟 120 | const properTime = parseFloat(document.getElementById('proper-time').value); 121 | staticClock.update(0, properTime); 122 | movingClock.update(0, properTime); 123 | 124 | console.log("时间膨胀动画已重置"); 125 | } 126 | 127 | sketch.draw = function() { 128 | // 获取当前参数 129 | const velocity = parseFloat(document.getElementById('velocity').value); 130 | const properTime = parseFloat(document.getElementById('proper-time').value); 131 | const gamma = 1 / Math.sqrt(1 - velocity * velocity); 132 | 133 | // 获取当前的isPlaying状态 134 | const isPlayingNow = window.isPlayingState || false; 135 | 136 | // 检测播放状态变化 137 | if (isPlayingNow !== prevPlayState) { 138 | if (isPlayingNow) { 139 | // 从暂停到播放,更新lastTime以避免时间跳跃 140 | lastTime = sketch.millis() / 1000; 141 | } else { 142 | // 从播放到暂停,记录当前时间 143 | pauseTime = time; 144 | } 145 | prevPlayState = isPlayingNow; 146 | } 147 | 148 | // 更新时间 149 | if (isPlayingNow) { 150 | const currentTime = sketch.millis() / 1000; 151 | const deltaTime = currentTime - lastTime; 152 | lastTime = currentTime; 153 | time += deltaTime * 0.5; // 减慢动画速度 154 | 155 | // 循环动画 156 | if (time > properTime) { 157 | time = 0; 158 | } 159 | } else { 160 | // 暂停状态,使用记录的暂停时间 161 | time = pauseTime; 162 | } 163 | 164 | // 计算时钟时间 165 | const staticTime = time; 166 | const movingTime = time / gamma; // 运动参考系时间变慢 167 | 168 | // 绘制背景 169 | sketch.background(245, 245, 250); 170 | 171 | // 绘制参考系 172 | drawReferenceFrame(sketch, sketch.width * 0.25, sketch.height * 0.2, 0); 173 | drawReferenceFrame(sketch, sketch.width * 0.75, sketch.height * 0.2, velocity); 174 | 175 | // 绘制时钟 176 | staticClock.update(staticTime, properTime); 177 | movingClock.update(movingTime, properTime); 178 | staticClock.display(sketch); 179 | movingClock.display(sketch); 180 | 181 | // 绘制时间信息 182 | sketch.fill(30); 183 | sketch.noStroke(); 184 | sketch.textSize(16); 185 | sketch.text(`时间: ${staticTime.toFixed(1)} 秒`, sketch.width * 0.25, sketch.height * 0.85); 186 | sketch.text(`时间: ${movingTime.toFixed(1)} 秒`, sketch.width * 0.75, sketch.height * 0.85); 187 | 188 | // 绘制连接线,表示同时性 189 | sketch.stroke(100, 100, 255, 100); 190 | sketch.strokeWeight(2); 191 | sketch.line( 192 | staticClock.x + staticClock.radius * Math.cos(staticClock.angle), 193 | staticClock.y + staticClock.radius * Math.sin(staticClock.angle), 194 | movingClock.x + movingClock.radius * Math.cos(movingClock.angle), 195 | movingClock.y + movingClock.radius * Math.sin(movingClock.angle) 196 | ); 197 | 198 | // 更新计算结果 199 | updateTimeDilationResults(velocity, properTime, gamma); 200 | }; 201 | 202 | // 窗口大小改变时调整画布大小 203 | sketch.windowResized = function() { 204 | const container = document.getElementById('time-dilation-animation'); 205 | sketch.resizeCanvas(container.offsetWidth, container.offsetHeight); 206 | 207 | // 重新定位时钟 208 | staticClock.x = sketch.width * 0.25; 209 | staticClock.y = sketch.height * 0.5; 210 | movingClock.x = sketch.width * 0.75; 211 | movingClock.y = sketch.height * 0.5; 212 | }; 213 | }, container); // 直接将容器传递给p5构造函数 214 | } 215 | 216 | // 更新时间膨胀计算结果 217 | function updateTimeDilationResults(velocity, properTime, gamma) { 218 | const dilatedTime = properTime * gamma; 219 | document.getElementById('lorentz-factor').textContent = gamma.toFixed(3); 220 | document.getElementById('dilated-time').textContent = dilatedTime.toFixed(2) + " 秒"; 221 | } -------------------------------------------------------------------------------- /simulations/waves/transverse/particle_motion.js: -------------------------------------------------------------------------------- 1 | // y-t 质点运动图相关代码 2 | let particleMotionInstance; 3 | let motionHistory = []; // 用于存储运动历史 4 | let motionStartTime = 0; // 记录开始绘制的时间点 5 | let maxDisplayTime = 4; // 最大显示时间跨度(秒) 6 | 7 | // 重置运动历史 8 | function resetMotionHistory() { 9 | motionHistory = []; 10 | motionStartTime = time; // 记录选中点击时的时间 11 | } 12 | 13 | // y-t 质点运动图 14 | const particleMotionSketch = function(p) { 15 | let canvasWidth, canvasHeight; 16 | const margin = { top: 50, right: 20, bottom: 40, left: 70 }; // 增加顶部边距和左侧边距 17 | let plotWidth, plotHeight; 18 | const maxHistoryPoints = 300; // 最大历史点数量 19 | 20 | p.setup = function() { 21 | const container = document.getElementById('particle-motion-container'); 22 | canvasWidth = container.offsetWidth; 23 | canvasHeight = container.offsetHeight || 300; // 确保高度匹配HTML设置 24 | 25 | const canvas = p.createCanvas(canvasWidth, canvasHeight); 26 | canvas.parent('particle-motion-container'); 27 | 28 | // 计算实际绘图区域 29 | plotWidth = canvasWidth - margin.left - margin.right; 30 | plotHeight = canvasHeight - margin.top - margin.bottom; 31 | 32 | // 设置帧率 33 | p.frameRate(60); 34 | }; 35 | 36 | p.draw = function() { 37 | p.background(255); 38 | 39 | // 显示图表标题 40 | p.fill(75); 41 | p.noStroke(); 42 | p.textSize(12); 43 | p.textAlign(p.LEFT, p.TOP); 44 | p.text("质点振动图 (y-t图)", 10, 10); 45 | 46 | // 绘制网格 47 | if (showGrid) { 48 | drawGrid(); 49 | } 50 | 51 | // 绘制坐标轴 52 | drawAxes(); 53 | 54 | // 如果有选中的质点,绘制其运动图 55 | if (selectedParticleX !== null) { 56 | // 如果没有暂停,添加新的数据点 57 | if (!isPaused) { 58 | addDataPoint(selectedParticleX, time); 59 | } 60 | 61 | // 绘制运动历史 62 | drawMotionHistory(); 63 | 64 | // 显示选中质点的信息 65 | p.fill(50); 66 | p.noStroke(); 67 | p.textSize(12); 68 | p.textAlign(p.LEFT, p.TOP); 69 | p.text("选中位置: x = " + selectedParticleX.toFixed(2) + " m", margin.left, margin.top - 20); 70 | } else { 71 | // 提示用户选择质点 72 | p.fill(150); 73 | p.noStroke(); 74 | p.textSize(14); 75 | p.textAlign(p.CENTER, p.CENTER); 76 | p.text("请在上方y-x图中点击选择一个质点位置", canvasWidth / 2, canvasHeight / 2); 77 | } 78 | }; 79 | 80 | // 添加数据点到历史记录 81 | function addDataPoint(x, currentTime) { 82 | // 计算物理y值 - 使用波动方程 83 | const y = amplitude * Math.sin(waveNumber * x - direction * angularFrequency * currentTime); 84 | 85 | // 仅当时间大于等于选中时刻才记录 86 | if (currentTime >= motionStartTime) { 87 | motionHistory.push({ 88 | time: currentTime - motionStartTime, // 相对时间,从0开始 89 | displacement: y 90 | }); 91 | 92 | // 限制历史点数量 93 | if (motionHistory.length > maxHistoryPoints) { 94 | // 当超过最大点数时,保留后面的点 95 | motionHistory.shift(); 96 | } 97 | } 98 | } 99 | 100 | // 绘制运动历史 101 | function drawMotionHistory() { 102 | if (motionHistory.length < 2) return; 103 | 104 | // 获取数据的时间范围 105 | let latestTime = motionHistory[motionHistory.length - 1].time; 106 | let displayEndTime = Math.max(maxDisplayTime, latestTime); 107 | 108 | p.stroke(94, 106, 210); 109 | p.strokeWeight(2); 110 | p.noFill(); 111 | 112 | p.beginShape(); 113 | for (let i = 0; i < motionHistory.length; i++) { 114 | const data = motionHistory[i]; 115 | 116 | // 将物理坐标映射到像素坐标 117 | const px = p.map(data.time, 0, displayEndTime, margin.left, canvasWidth - margin.right); 118 | const py = p.map(data.displacement, amplitude, -amplitude, margin.top, canvasHeight - margin.bottom); 119 | 120 | p.vertex(px, py); 121 | } 122 | p.endShape(); 123 | 124 | // 绘制当前点 - 显示当前位移 125 | if (motionHistory.length > 0) { 126 | // 获取当前数据 127 | const currentData = motionHistory[motionHistory.length - 1]; 128 | 129 | // 计算当前位置 130 | const px = p.map(currentData.time, 0, displayEndTime, margin.left, canvasWidth - margin.right); 131 | const py = p.map(currentData.displacement, amplitude, -amplitude, margin.top, canvasHeight - margin.bottom); 132 | 133 | p.fill(245, 158, 11); // 橙色 134 | p.stroke(200, 75, 0); 135 | p.strokeWeight(1); 136 | p.ellipse(px, py, 8, 8); 137 | 138 | // 添加文本说明此点位置 139 | p.fill(200, 75, 0); 140 | p.noStroke(); 141 | p.textSize(10); 142 | p.textAlign(p.LEFT, p.CENTER); 143 | p.text("当前位置", px + 10, py); 144 | } 145 | } 146 | 147 | // 绘制坐标轴 148 | function drawAxes() { 149 | p.stroke(100); 150 | p.strokeWeight(1); 151 | 152 | // 计算中心y位置(平衡位置) 153 | const middleY = margin.top + plotHeight / 2; 154 | 155 | // x轴 - 现在位于中心位置 156 | p.line(margin.left, middleY, canvasWidth - margin.right, middleY); 157 | 158 | // y轴 159 | p.line(margin.left, margin.top, margin.left, canvasHeight - margin.bottom); 160 | 161 | // 计算最大显示时间 162 | let displayEndTime = maxDisplayTime; 163 | if (motionHistory.length > 0) { 164 | const latestTime = motionHistory[motionHistory.length - 1].time; 165 | displayEndTime = Math.max(maxDisplayTime, latestTime); 166 | } 167 | 168 | // x轴刻度 - 均匀分布5个刻度 169 | p.textSize(10); 170 | p.textAlign(p.CENTER, p.TOP); 171 | p.fill(100); 172 | 173 | const timeStep = displayEndTime / 4; // 计算时间步长 174 | 175 | for (let i = 0; i <= 4; i++) { 176 | const t = i * timeStep; 177 | const x = margin.left + (i / 4) * plotWidth; 178 | p.line(x, middleY - 3, x, middleY + 3); 179 | p.text(t.toFixed(1) + "s", x, middleY + 5); 180 | } 181 | 182 | // y轴刻度 183 | p.textAlign(p.RIGHT, p.CENTER); 184 | 185 | // y = 0 (在中心) 186 | p.line(margin.left - 5, middleY, margin.left, middleY); 187 | p.text("0", margin.left - 8, middleY); 188 | 189 | // y = +A 190 | const posA = margin.top; 191 | p.line(margin.left - 5, posA, margin.left, posA); 192 | p.text("+A", margin.left - 8, posA); 193 | 194 | // y = -A 195 | const negA = canvasHeight - margin.bottom; 196 | p.line(margin.left - 5, negA, margin.left, negA); 197 | p.text("-A", margin.left - 8, negA); 198 | 199 | // 轴标签 200 | p.textAlign(p.CENTER, p.TOP); 201 | p.text("t (s)", margin.left + plotWidth / 2, canvasHeight - 10); 202 | 203 | p.push(); 204 | p.translate(25, margin.top + plotHeight / 2); 205 | p.rotate(-p.HALF_PI); 206 | p.text("y (m)", 0, 0); 207 | p.pop(); 208 | } 209 | 210 | // 绘制网格 211 | function drawGrid() { 212 | p.stroke(240); 213 | p.strokeWeight(0.5); 214 | 215 | // 计算中心y位置 216 | const middleY = margin.top + plotHeight / 2; 217 | 218 | // 计算最大显示时间 219 | let displayEndTime = maxDisplayTime; 220 | if (motionHistory.length > 0) { 221 | const latestTime = motionHistory[motionHistory.length - 1].time; 222 | displayEndTime = Math.max(maxDisplayTime, latestTime); 223 | } 224 | 225 | // 垂直网格线 226 | for (let i = 0; i <= 4; i++) { 227 | const x = margin.left + (i / 4) * plotWidth; 228 | p.line(x, margin.top, x, canvasHeight - margin.bottom); 229 | } 230 | 231 | // 水平网格线 - 确保中间有一条线 232 | // 上半部分 233 | for (let i = 0; i <= 2; i++) { 234 | const y = middleY - (i / 2) * (plotHeight / 2); 235 | p.line(margin.left, y, canvasWidth - margin.right, y); 236 | } 237 | 238 | // 下半部分 239 | for (let i = 1; i <= 2; i++) { 240 | const y = middleY + (i / 2) * (plotHeight / 2); 241 | p.line(margin.left, y, canvasWidth - margin.right, y); 242 | } 243 | } 244 | 245 | p.windowResized = function() { 246 | const container = document.getElementById('particle-motion-container'); 247 | canvasWidth = container.offsetWidth; 248 | p.resizeCanvas(canvasWidth, canvasHeight); 249 | 250 | // 重新计算绘图区域 251 | plotWidth = canvasWidth - margin.left - margin.right; 252 | plotHeight = canvasHeight - margin.top - margin.bottom; 253 | }; 254 | }; 255 | 256 | // 初始化质点运动图 257 | function initParticleMotion() { 258 | particleMotionInstance = new p5(particleMotionSketch); 259 | } -------------------------------------------------------------------------------- /simulations/waves/transverse/transverse_main.js: -------------------------------------------------------------------------------- 1 | // 全局变量 2 | let isPaused = true; // 默认暂停状态 3 | let showParticles = true; 4 | let showWavefront = true; 5 | let showGrid = false; 6 | let time = 0; // 全局时间计数器 7 | let selectedParticleX = null; // 保存用户选择的粒子X坐标 8 | 9 | // 波动参数 10 | let amplitude = 0.5; // 振幅,单位:m 11 | let wavelength = 1.0; // 波长,单位:m 12 | let period = 1.0; // 周期,单位:s 13 | let waveSpeed = 1.0; // 波速,单位:m/s,根据波长和周期计算 14 | let direction = 1; // 传播方向:1表示向右,-1表示向左 15 | 16 | // 派生参数 17 | let frequency, angularFrequency, waveNumber; 18 | 19 | // DOM元素 20 | let pauseBtn, resetBtn, startBtn; 21 | let showParticlesCheck, showWavefrontCheck, showGridCheck; 22 | let directionRightRadio, directionLeftRadio; 23 | 24 | // 计算派生参数 25 | function calculateDerivedParameters() { 26 | frequency = 1 / period; // 频率=1/周期 27 | angularFrequency = 2 * Math.PI * frequency; // 角频率 28 | waveNumber = 2 * Math.PI / wavelength; // 波数 29 | waveSpeed = wavelength / period; // 波速=波长/周期 30 | 31 | // 更新显示 32 | document.getElementById('frequency').innerText = frequency.toFixed(2) + ' Hz'; 33 | document.getElementById('angular-frequency').innerText = angularFrequency.toFixed(2) + ' rad/s'; 34 | document.getElementById('wave-number').innerText = waveNumber.toFixed(2) + ' rad/m'; 35 | } 36 | 37 | // 初始化所有控件 38 | function initControls() { 39 | // 获取DOM元素 40 | pauseBtn = document.getElementById('pause-btn'); 41 | resetBtn = document.getElementById('reset-btn'); 42 | startBtn = document.getElementById('start-btn'); 43 | showParticlesCheck = document.getElementById('show-particles'); 44 | showWavefrontCheck = document.getElementById('show-wavefront'); 45 | showGridCheck = document.getElementById('show-grid'); 46 | directionRightRadio = document.getElementById('direction-right'); 47 | directionLeftRadio = document.getElementById('direction-left'); 48 | 49 | // 事件监听 50 | pauseBtn.addEventListener('click', function() { 51 | isPaused = true; 52 | this.innerHTML = '暂停'; 53 | startBtn.innerHTML = '开始'; 54 | }); 55 | 56 | startBtn.addEventListener('click', function() { 57 | isPaused = false; 58 | this.innerHTML = '运行中...'; 59 | pauseBtn.innerHTML = '暂停'; 60 | }); 61 | 62 | resetBtn.addEventListener('click', function() { 63 | // 重置参数到初始值 64 | time = 0; 65 | selectedParticleX = null; 66 | motionHistory = []; // 清空运动历史 67 | 68 | document.getElementById('period-input').value = 1.0; 69 | document.getElementById('period-value').textContent = 1.0; 70 | period = 1.0; 71 | 72 | document.getElementById('wavelength').value = 1.0; 73 | document.getElementById('wavelength-value').textContent = 1.0; 74 | wavelength = 1.0; 75 | 76 | document.getElementById('amplitude').value = 0.5; 77 | document.getElementById('amplitude-value').textContent = 0.5; 78 | amplitude = 0.5; 79 | 80 | directionRightRadio.checked = true; 81 | directionLeftRadio.checked = false; 82 | direction = 1; 83 | 84 | // 重置显示选项 85 | showParticlesCheck.checked = true; 86 | showParticles = true; 87 | 88 | showWavefrontCheck.checked = true; 89 | showWavefront = true; 90 | 91 | showGridCheck.checked = false; 92 | showGrid = false; 93 | 94 | // 重新计算派生参数 95 | calculateDerivedParameters(); 96 | 97 | isPaused = true; // 重置后暂停 98 | pauseBtn.innerHTML = '暂停'; 99 | startBtn.innerHTML = '开始'; 100 | }); 101 | 102 | // 参数变化监听 103 | document.getElementById('period-input').addEventListener('input', function() { 104 | period = parseFloat(this.value); 105 | document.getElementById('period-value').textContent = this.value; 106 | calculateDerivedParameters(); 107 | }); 108 | 109 | document.getElementById('wavelength').addEventListener('input', function() { 110 | wavelength = parseFloat(this.value); 111 | document.getElementById('wavelength-value').textContent = this.value; 112 | calculateDerivedParameters(); 113 | }); 114 | 115 | document.getElementById('amplitude').addEventListener('input', function() { 116 | amplitude = parseFloat(this.value); 117 | document.getElementById('amplitude-value').textContent = this.value; 118 | }); 119 | 120 | directionRightRadio.addEventListener('change', function() { 121 | if (this.checked) direction = 1; 122 | }); 123 | 124 | directionLeftRadio.addEventListener('change', function() { 125 | if (this.checked) direction = -1; 126 | }); 127 | 128 | showParticlesCheck.addEventListener('change', function() { 129 | showParticles = this.checked; 130 | }); 131 | 132 | showWavefrontCheck.addEventListener('change', function() { 133 | showWavefront = this.checked; 134 | }); 135 | 136 | showGridCheck.addEventListener('change', function() { 137 | showGrid = this.checked; 138 | }); 139 | } 140 | 141 | // 窗口加载完成后初始化 142 | window.addEventListener('load', function() { 143 | // 初始化控件 144 | initControls(); 145 | 146 | // 计算初始派生参数 147 | calculateDerivedParameters(); 148 | 149 | // 创建p5实例 150 | initWaveAnimation(); 151 | initWaveShape(); 152 | initParticleMotion(); 153 | }); -------------------------------------------------------------------------------- /simulations/waves/transverse/transverse_wave.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 横波理解与可视化 - 物理可视化教学 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 100 | 101 | 102 | 103 | 127 | 128 | 129 |
130 | 131 |
132 | 133 |
134 |
135 |

实验参数控制

136 | 137 |
138 | 139 |
140 | 144 | 145 |
146 | 147 | 148 |
149 | 153 | 154 |
155 | 156 | 157 |
158 | 162 | 163 |
164 | 165 | 166 |
167 | 168 |
169 |
170 | 171 | 172 |
173 |
174 | 175 | 176 |
177 |
178 |
179 | 180 | 181 |
182 |
183 | 184 | 185 |
186 |
187 | 188 | 189 |
190 |
191 | 192 | 193 |
194 |
195 | 196 | 197 |
198 | 201 | 204 | 207 |
208 |
209 |
210 | 211 | 212 |
213 |

物理量实时显示

214 | 215 |
216 |
217 |
频率 f:
218 |
1.00 Hz
219 |
220 | 221 |
222 |
角频率 ω:
223 |
6.28 rad/s
224 |
225 | 226 |
227 |
波数 k:
228 |
6.28 rad/m
229 |
230 |
231 |
232 |
233 | 234 | 235 |
236 | 237 |
238 |
239 |
240 | 241 | 242 |
243 |
244 |
245 | 246 | 247 |
248 |
249 |
250 | 251 | 252 |
253 |

横波基本原理

254 |

255 | 横波是指质点振动方向与波传播方向垂直的机械波。在横波中,介质中的质点沿垂直于波传播方向的方向振动,形成波峰和波谷。 256 |

257 |

258 | 横波的数学表达式可以表示为: 259 |

260 |
261 | y(x,t) = A·sin(kx - ωt + φ) 262 |
263 |

264 | 其中,A是振幅,k = 2π/λ是波数,ω = 2πf是角频率,φ是初相位。波速v = λf = ω/k。 265 |

266 |

267 | 横波的例子包括绳子上的波、水面上的部分波、电磁波等。理解横波对于学习波动学、声学和光学都有重要意义。 268 |

269 |
270 |
271 |
272 |
273 | 274 | 275 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | -------------------------------------------------------------------------------- /simulations/waves/transverse/wave_animation.js: -------------------------------------------------------------------------------- 1 | // 波动动画相关代码 2 | let waveAnimationInstance; 3 | 4 | // 横波动画 5 | const waveAnimationSketch = function(p) { 6 | let canvasWidth, canvasHeight; 7 | const numParticles = 20; // 显示的质点数量 8 | const particleSpacing = 30; // 质点间距,像素 9 | let particleArray = []; // 存储质点的位置 10 | 11 | p.setup = function() { 12 | const container = document.getElementById('wave-animation-container'); 13 | canvasWidth = container.offsetWidth; 14 | canvasHeight = container.offsetHeight || 200; 15 | 16 | const canvas = p.createCanvas(canvasWidth, canvasHeight); 17 | canvas.parent('wave-animation-container'); 18 | 19 | // 初始化质点位置 20 | for (let i = 0; i < numParticles; i++) { 21 | particleArray.push({ 22 | x: i * particleSpacing + 50, // 起始x位置 23 | y: canvasHeight / 2, // 初始y位置(中间) 24 | initialX: i * particleSpacing + 50 // 保存初始x位置用于计算 25 | }); 26 | } 27 | 28 | // 设置帧率 29 | p.frameRate(60); 30 | }; 31 | 32 | p.draw = function() { 33 | p.background(245, 247, 250); 34 | 35 | // 显示图表标题 36 | p.fill(75); 37 | p.noStroke(); 38 | p.textSize(12); 39 | p.textAlign(p.LEFT, p.TOP); 40 | p.text("横波动画", 10, 10); 41 | 42 | // 显示波速 43 | p.textAlign(p.RIGHT, p.TOP); 44 | p.fill(16, 185, 129); // 绿色 45 | p.textSize(12); 46 | p.text("波速: " + waveSpeed.toFixed(2) + " m/s", canvasWidth - 10, 10); 47 | 48 | // 如果未暂停,更新时间 49 | if (!isPaused) { 50 | time += 0.016; // 假设60fps,约16ms 51 | } 52 | 53 | // 绘制网格 54 | if (showGrid) { 55 | drawGrid(); 56 | } 57 | 58 | // 平衡线 59 | p.stroke(200); 60 | p.strokeWeight(1); 61 | p.line(0, canvasHeight / 2, canvasWidth, canvasHeight / 2); 62 | 63 | // 绘制平衡位置文字标记 64 | p.fill(120); 65 | p.noStroke(); 66 | p.textAlign(p.RIGHT); 67 | p.textSize(10); 68 | p.text("平衡位置", 45, canvasHeight / 2 - 5); 69 | 70 | // 更新并绘制质点位置 71 | updateParticles(); 72 | 73 | // 绘制波前 74 | if (showWavefront) { 75 | drawWavefront(); 76 | } 77 | 78 | // 绘制传播方向 79 | drawPropagationDirection(); 80 | 81 | // 在底部添加提示文字 82 | p.fill(150); // 灰色 83 | p.textSize(10); 84 | p.textAlign(p.CENTER, p.BOTTOM); 85 | p.text("调节波长时,此处动画不支持显示波长变化", canvasWidth / 2, canvasHeight - 5); 86 | }; 87 | 88 | // 绘制网格 89 | function drawGrid() { 90 | p.stroke(220); 91 | p.strokeWeight(0.5); 92 | 93 | // 绘制垂直线 94 | for (let x = 0; x < canvasWidth; x += 50) { 95 | p.line(x, 0, x, canvasHeight); 96 | } 97 | 98 | // 绘制水平线 99 | for (let y = 0; y < canvasHeight; y += 50) { 100 | p.line(0, y, canvasWidth, y); 101 | } 102 | } 103 | 104 | // 绘制传播方向 105 | function drawPropagationDirection() { 106 | p.stroke(16, 185, 129); // 绿色 107 | p.strokeWeight(1.5); 108 | p.fill(16, 185, 129); 109 | 110 | // 根据当前传播方向绘制箭头,放在画布中间位置 111 | const arrowX = canvasWidth / 2; 112 | const arrowEndX = direction > 0 ? arrowX + 40 : arrowX - 40; 113 | 114 | // 箭头线 115 | p.line(arrowX, 30, arrowEndX, 30); 116 | 117 | // 箭头头部 118 | const headSize = 8; 119 | if (direction > 0) { 120 | p.triangle( 121 | arrowEndX, 30, 122 | arrowEndX - headSize, 30 - headSize / 2, 123 | arrowEndX - headSize, 30 + headSize / 2 124 | ); 125 | } else { 126 | p.triangle( 127 | arrowEndX, 30, 128 | arrowEndX + headSize, 30 - headSize / 2, 129 | arrowEndX + headSize, 30 + headSize / 2 130 | ); 131 | } 132 | 133 | // 添加标签 134 | p.noStroke(); 135 | p.textAlign(p.CENTER, p.TOP); 136 | p.text("传播方向", arrowX, 35); 137 | } 138 | 139 | // 更新质点位置 140 | function updateParticles() { 141 | for (let i = 0; i < numParticles; i++) { 142 | const particle = particleArray[i]; 143 | 144 | // 使用实际物理位置计算 145 | const xPos = (particle.initialX - 50) / particleSpacing * (wavelength / numParticles * 5); 146 | 147 | // 计算y位置 (振动位移) 148 | particle.y = canvasHeight / 2 - amplitude * 40 * 149 | Math.sin(waveNumber * xPos - direction * angularFrequency * time); 150 | 151 | // 绘制质点 152 | if (showParticles) { 153 | p.fill(94, 106, 210); 154 | p.noStroke(); 155 | p.ellipse(particle.x, particle.y, 10, 10); 156 | 157 | // 连接线 158 | if (i > 0) { 159 | p.stroke(94, 106, 210, 150); 160 | p.strokeWeight(2); 161 | p.line(particleArray[i-1].x, particleArray[i-1].y, particle.x, particle.y); 162 | } 163 | } 164 | } 165 | } 166 | 167 | // 绘制波前 168 | function drawWavefront() { 169 | p.stroke(16, 185, 129); // 绿色 170 | p.strokeWeight(2); 171 | p.noFill(); 172 | 173 | p.beginShape(); 174 | for (let x = 0; x < canvasWidth; x += 5) { 175 | // 将像素坐标转换为物理坐标 176 | const xPos = (x - 50) / particleSpacing * (wavelength / numParticles * 5); 177 | 178 | // 计算y位置 179 | const y = canvasHeight / 2 - amplitude * 40 * 180 | Math.sin(waveNumber * xPos - direction * angularFrequency * time); 181 | 182 | p.vertex(x, y); 183 | } 184 | p.endShape(); 185 | } 186 | 187 | p.windowResized = function() { 188 | const container = document.getElementById('wave-animation-container'); 189 | canvasWidth = container.offsetWidth; 190 | p.resizeCanvas(canvasWidth, canvasHeight); 191 | 192 | // 重新计算质点位置 193 | for (let i = 0; i < numParticles; i++) { 194 | particleArray[i] = { 195 | x: i * particleSpacing + 50, 196 | y: canvasHeight / 2, 197 | initialX: i * particleSpacing + 50 198 | }; 199 | } 200 | }; 201 | }; 202 | 203 | // 初始化波动动画 204 | function initWaveAnimation() { 205 | waveAnimationInstance = new p5(waveAnimationSketch); 206 | } -------------------------------------------------------------------------------- /simulations/waves/transverse/wave_shape.js: -------------------------------------------------------------------------------- 1 | // y-x 波形图相关代码 2 | let waveShapeInstance; 3 | 4 | // y-x 波形图 5 | const waveShapeSketch = function(p) { 6 | let canvasWidth, canvasHeight; 7 | const margin = { top: 50, right: 20, bottom: 40, left: 40 }; // 增加顶部边距,使图形下移 8 | let plotWidth, plotHeight; 9 | let selectedX = null; 10 | 11 | p.setup = function() { 12 | const container = document.getElementById('wave-shape-container'); 13 | canvasWidth = container.offsetWidth; 14 | canvasHeight = container.offsetHeight || 300; // 确保高度匹配HTML设置 15 | 16 | const canvas = p.createCanvas(canvasWidth, canvasHeight); 17 | canvas.parent('wave-shape-container'); 18 | 19 | // 计算实际绘图区域 20 | plotWidth = canvasWidth - margin.left - margin.right; 21 | plotHeight = canvasHeight - margin.top - margin.bottom; 22 | 23 | // 点击事件监听 24 | canvas.mouseClicked(handleMouseClick); 25 | 26 | // 设置帧率 27 | p.frameRate(60); 28 | }; 29 | 30 | p.draw = function() { 31 | p.background(255); 32 | 33 | // 显示图表标题 34 | p.fill(75); 35 | p.noStroke(); 36 | p.textSize(12); 37 | p.textAlign(p.LEFT, p.TOP); 38 | p.text("横波空间分布 (y-x图)", 10, 10); 39 | 40 | // 绘制网格 41 | if (showGrid) { 42 | drawGrid(); 43 | } 44 | 45 | // 绘制坐标轴 46 | drawAxes(); 47 | 48 | // 绘制波形 49 | drawWaveShape(); 50 | 51 | // 如果有选中的点,绘制标记 52 | if (selectedX !== null) { 53 | drawSelectedPoint(selectedX); 54 | } 55 | 56 | // 确保提示文字始终为暗灰色,重置所有绘图设置 57 | p.push(); 58 | p.noStroke(); 59 | p.fill(120, 120, 120); // 更明确的暗灰色RGB值 60 | p.textSize(10); 61 | p.textAlign(p.RIGHT, p.BOTTOM); 62 | p.text("点击曲线上的位置可查看该点的时间振动图", canvasWidth - 10, canvasHeight - 5); 63 | p.pop(); 64 | }; 65 | 66 | // 处理鼠标点击 67 | function handleMouseClick() { 68 | // 检查鼠标是否在绘图区域内 69 | if (p.mouseX >= margin.left && p.mouseX <= canvasWidth - margin.right && 70 | p.mouseY >= margin.top && p.mouseY <= canvasHeight - margin.bottom) { 71 | 72 | // 将鼠标位置转换为物理坐标 73 | const physicalX = (p.mouseX - margin.left) / plotWidth * 2 * wavelength - wavelength; 74 | 75 | // 保存选中的x位置 76 | selectedX = physicalX; 77 | selectedParticleX = physicalX; 78 | 79 | // 重置y-t图的时间历史 80 | resetMotionHistory(); 81 | 82 | // 重绘 83 | p.redraw(); 84 | } 85 | } 86 | 87 | // 绘制波形 88 | function drawWaveShape() { 89 | p.stroke(94, 106, 210); 90 | p.strokeWeight(2); 91 | p.noFill(); 92 | 93 | p.beginShape(); 94 | for (let i = 0; i <= 100; i++) { 95 | // 将[0, 100]映射到[-wavelength, wavelength] 96 | const x = (i / 100) * 2 * wavelength - wavelength; 97 | 98 | // 计算y值 - 使用波动方程 99 | const y = amplitude * Math.sin(waveNumber * x - direction * angularFrequency * time); 100 | 101 | // 将物理坐标映射到像素坐标 102 | const px = p.map(x, -wavelength, wavelength, margin.left, canvasWidth - margin.right); 103 | const py = p.map(y, amplitude, -amplitude, margin.top, canvasHeight - margin.bottom); 104 | 105 | p.vertex(px, py); 106 | } 107 | p.endShape(); 108 | } 109 | 110 | // 绘制坐标轴 111 | function drawAxes() { 112 | p.stroke(100); 113 | p.strokeWeight(1); 114 | 115 | // 计算中心y位置(平衡位置) 116 | const middleY = margin.top + plotHeight / 2; 117 | 118 | // x轴 - 现在位于中心位置 119 | p.line(margin.left, middleY, canvasWidth - margin.right, middleY); 120 | 121 | // y轴 122 | p.line(margin.left, margin.top, margin.left, canvasHeight - margin.bottom); 123 | 124 | // x轴刻度 125 | p.textSize(10); 126 | p.textAlign(p.CENTER, p.BOTTOM); 127 | p.fill(100); 128 | 129 | // 中心点 130 | const centerX = margin.left + plotWidth / 2; 131 | p.line(centerX, middleY - 5, centerX, middleY + 5); 132 | p.text("0", centerX, middleY + 18); 133 | 134 | // -λ/2点 135 | const minusHalfLambda = margin.left + plotWidth / 4; 136 | p.line(minusHalfLambda, middleY - 5, minusHalfLambda, middleY + 5); 137 | p.text("-λ/2", minusHalfLambda, middleY + 18); 138 | 139 | // +λ/2点 140 | const plusHalfLambda = margin.left + plotWidth * 3 / 4; 141 | p.line(plusHalfLambda, middleY - 5, plusHalfLambda, middleY + 5); 142 | p.text("+λ/2", plusHalfLambda, middleY + 18); 143 | 144 | // y轴刻度 145 | p.textAlign(p.RIGHT, p.CENTER); 146 | 147 | // y = 0 (现在在中心) 148 | p.line(margin.left - 5, middleY, margin.left, middleY); 149 | p.text("0", margin.left - 8, middleY); 150 | 151 | // y = +A 152 | const posA = margin.top; 153 | p.line(margin.left - 5, posA, margin.left, posA); 154 | p.text("+A", margin.left - 8, posA); 155 | 156 | // y = -A 157 | const negA = canvasHeight - margin.bottom; 158 | p.line(margin.left - 5, negA, margin.left, negA); 159 | p.text("-A", margin.left - 8, negA); 160 | 161 | // 轴标签 162 | p.textAlign(p.CENTER, p.TOP); 163 | p.text("x (m)", margin.left + plotWidth / 2, canvasHeight - 10); 164 | 165 | p.push(); 166 | p.translate(15, margin.top + plotHeight / 2); 167 | p.rotate(-p.HALF_PI); 168 | p.text("y (m)", 0, 0); 169 | p.pop(); 170 | } 171 | 172 | // 绘制网格 173 | function drawGrid() { 174 | p.stroke(240); 175 | p.strokeWeight(0.5); 176 | 177 | // 垂直网格线 178 | for (let i = 0; i <= 4; i++) { 179 | const x = margin.left + (i / 4) * plotWidth; 180 | p.line(x, margin.top, x, canvasHeight - margin.bottom); 181 | } 182 | 183 | // 水平网格线 - 确保中间有一条线 184 | // 计算中心y位置 185 | const middleY = margin.top + plotHeight / 2; 186 | 187 | // 上半部分 188 | for (let i = 0; i <= 2; i++) { 189 | const y = middleY - (i / 2) * (plotHeight / 2); 190 | p.line(margin.left, y, canvasWidth - margin.right, y); 191 | } 192 | 193 | // 下半部分 194 | for (let i = 1; i <= 2; i++) { 195 | const y = middleY + (i / 2) * (plotHeight / 2); 196 | p.line(margin.left, y, canvasWidth - margin.right, y); 197 | } 198 | } 199 | 200 | // 绘制选中的点 201 | function drawSelectedPoint(x) { 202 | // 计算物理y值 203 | const y = amplitude * Math.sin(waveNumber * x - direction * angularFrequency * time); 204 | 205 | // 将物理坐标映射到像素坐标 206 | const px = p.map(x, -wavelength, wavelength, margin.left, canvasWidth - margin.right); 207 | const py = p.map(y, amplitude, -amplitude, margin.top, canvasHeight - margin.bottom); 208 | 209 | // 绘制选中点 210 | p.fill(245, 158, 11); // 橙色 211 | p.stroke(200, 75, 0); 212 | p.strokeWeight(1); 213 | p.ellipse(px, py, 8, 8); 214 | 215 | // 计算中心y位置(平衡位置) 216 | const middleY = margin.top + plotHeight / 2; 217 | 218 | // 垂直线到x轴 219 | p.stroke(200, 75, 0, 150); 220 | p.strokeWeight(1); 221 | p.line(px, py, px, middleY); 222 | 223 | // 水平线到y轴 224 | p.line(px, py, margin.left, py); 225 | 226 | // 显示坐标值 227 | p.fill(50); 228 | p.noStroke(); 229 | p.textSize(10); 230 | p.textAlign(p.CENTER, p.TOP); 231 | 232 | // 在选定点下方显示x坐标 233 | if (py < middleY) { 234 | p.text("x = " + x.toFixed(2) + " m", px, middleY + 20); 235 | } else { 236 | // 如果点在x轴下方,则在上方显示 237 | p.text("x = " + x.toFixed(2) + " m", px, middleY - 15); 238 | } 239 | 240 | // 显示y坐标 241 | p.textAlign(p.RIGHT, p.CENTER); 242 | p.text("y = " + y.toFixed(2) + " m", margin.left - 10, py); 243 | } 244 | 245 | p.windowResized = function() { 246 | const container = document.getElementById('wave-shape-container'); 247 | canvasWidth = container.offsetWidth; 248 | p.resizeCanvas(canvasWidth, canvasHeight); 249 | 250 | // 重新计算绘图区域 251 | plotWidth = canvasWidth - margin.left - margin.right; 252 | plotHeight = canvasHeight - margin.top - margin.bottom; 253 | }; 254 | }; 255 | 256 | // 初始化波形图 257 | function initWaveShape() { 258 | waveShapeInstance = new p5(waveShapeSketch); 259 | } -------------------------------------------------------------------------------- /versions/version_history.txt: -------------------------------------------------------------------------------- 1 | # 物理可视化教学平台 - 版本历史 2 | 3 | ## 版本 1.0.0 (2025年3月13日) 4 | 5 | ### 新功能 6 | - 初始版本发布 7 | - 包含7个物理模拟实验:双缝干涉、简谐运动、波的二维干涉、弹簧振子、多普勒效应、机械波叠加、狭义相对论 8 | - 实现响应式设计,适配各种设备 9 | - 添加交互式控制面板,可调整物理参数 10 | 11 | ### 技术栈 12 | - 前端:HTML5 + TailwindCSS + JavaScript 13 | - 物理模拟:p5.js 14 | - 数学处理:math.js 15 | 16 | ### 贡献者 17 | - Lisa (主要开发者) --------------------------------------------------------------------------------