| <!DOCTYPE html> |
| <html lang="zh-TW"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>TaiScience CSD-EWS: Pole & Damping Ratio Simulator</title> |
| <style> |
| body { background-color: #121212; color: #e0e0e0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; line-height: 1.6; } |
| .container { max-width: 1200px; margin: 0 auto; } |
| .header { text-align: center; margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 15px; position: relative; } |
| .header h1 { margin: 0; color: #4dabf7; font-size: 28px; font-weight: 600; letter-spacing: 1px; } |
| .header p { color: #aaa; font-size: 15px; margin-top: 8px; max-width: 900px; margin-left: auto; margin-right: auto; } |
| .lang-switch { position: absolute; top: 0; right: 0; display: flex; gap: 6px; } |
| .lang-switch button { |
| background: #252525; color: #aaa; border: 1px solid #444; border-radius: 6px; |
| padding: 6px 12px; cursor: pointer; font-size: 13px; transition: all 0.2s; |
| } |
| .lang-switch button:hover { border-color: #4dabf7; color: #e0e0e0; } |
| .lang-switch button.active { background: #4dabf7; color: #121212; border-color: #4dabf7; font-weight: 600; } |
| .grid { display: grid; grid-template-columns: 1fr 2fr; gap: 20px; } |
| .controls { background: #1e1e1e; padding: 25px; border-radius: 8px; border: 1px solid #333; box-shadow: 0 4px 6px rgba(0,0,0,0.3); } |
| .visuals { display: flex; flex-direction: column; gap: 20px; } |
| .panel { background: #1e1e1e; padding: 20px; border-radius: 8px; border: 1px solid #333; box-shadow: 0 4px 6px rgba(0,0,0,0.3); } |
| h3 { margin-top: 0; color: #fff; font-size: 18px; border-bottom: 1px solid #444; padding-bottom: 10px; margin-bottom: 20px; } |
| label { display: block; margin-top: 20px; margin-bottom: 8px; font-weight: 500; font-size: 15px; color: #ccc; } |
| span.val { float: right; color: #4dabf7; font-weight: bold; } |
| input[type=range] { width: 100%; cursor: pointer; accent-color: #4dabf7; } |
| canvas { width: 100%; height: 260px; background: #0a0a0a; border: 1px solid #444; border-radius: 6px; } |
| .metric { font-size: 1.3em; font-weight: bold; margin-top: 30px; padding: 20px; background: #252525; border-radius: 8px; text-align: center; border: 2px solid #333; transition: border-color 0.3s; } |
| .metric .zeta-row { margin-bottom: 10px; } |
| .metric .status-label { font-size: 0.8em; color: #888; } |
| .safe { color: #51cf66; } |
| .warning { color: #fcc419; } |
| .danger { color: #ff6b6b; } |
| .math-notes { color: #888; font-size: 13px; margin-top: 30px; background: #1a1a1a; padding: 15px; border-radius: 6px; border-left: 3px solid #4dabf7; } |
| .math-notes .disclaimer { display: block; margin-top: 12px; color: #666; font-style: italic; } |
| .time-caption { font-size: 12px; color: #666; margin: -12px 0 10px 0; } |
| @media (max-width: 800px) { |
| .grid { grid-template-columns: 1fr; } |
| .lang-switch { position: static; justify-content: center; margin-bottom: 12px; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <div class="header"> |
| <div class="lang-switch"> |
| <button type="button" id="lang-zh" class="active" aria-pressed="true">中文</button> |
| <button type="button" id="lang-en" aria-pressed="false">English</button> |
| </div> |
| <h1 id="title-main">TaiScience CSD-EWS</h1> |
| <p id="title-sub">動態系統極點與阻尼比實時模擬器 | Jacobian 特徵值代理特徵(教學示範)</p> |
| </div> |
| <div class="grid"> |
| <div class="controls"> |
| <h3 id="controls-heading">系統特徵值參數 (System Poles)</h3> |
|
|
| <label id="label-sigma">實部 (σ, 衰減率/生長率): <span class="val" id="sigma-val">-0.50</span></label> |
| <input type="range" id="sigma" min="-2.0" max="0.5" step="0.01" value="-0.5"> |
|
|
| <label id="label-omega">虛部 (ω, 振盪頻率): <span class="val" id="omega-val">5.0</span></label> |
| <input type="range" id="omega" min="1.0" max="15.0" step="0.1" value="5.0"> |
|
|
| <div class="metric" id="status-box"> |
| <div class="zeta-row"><span id="zeta-label">阻尼比 (ζ):</span> <span id="zeta-val" style="font-size: 1.2em;">0.1000</span></div> |
| <div class="status-label" id="status-label">狀態判別 (ζ 閾值 5%):</div> |
| <div id="status-text" class="safe" style="margin-top: 5px;"></div> |
| </div> |
|
|
| <div class="math-notes" id="math-notes"></div> |
| </div> |
|
|
| <div class="visuals"> |
| <div class="panel"> |
| <h3 id="splane-heading">複數平面 (s-plane) 映射</h3> |
| <canvas id="s-plane"></canvas> |
| </div> |
| <div class="panel"> |
| <h3 id="time-heading">時域響應(示意波形)</h3> |
| <p class="time-caption" id="time-caption"></p> |
| <canvas id="time-domain"></canvas> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const ZETA_WEAK_THRESHOLD = 0.05; |
| |
| const I18N = { |
| 'zh-TW': { |
| pageTitle: 'TaiScience CSD-EWS: 動態系統極點與阻尼比分析', |
| titleMain: 'TaiScience CSD-EWS', |
| titleSub: '動態系統極點與阻尼比實時模擬器 | Jacobian 特徵值代理特徵(教學示範)', |
| controlsHeading: '系統特徵值參數 (System Poles)', |
| labelSigma: '實部 (σ, 衰減率/生長率):', |
| labelOmega: '虛部 (ω, 振盪頻率):', |
| zetaLabel: '阻尼比 (ζ):', |
| statusLabel: '狀態判別 (ζ 閾值 5%):', |
| statusDanger: '不穩定 / 負阻尼 (σ > 0)', |
| statusWarning: '弱阻尼 (ζ < 5%)', |
| statusSafe: '安全阻尼 (ζ ≥ 5%)', |
| mathNotes: '<strong>核心公式(共軛極點假設):</strong><br><br>' |
| + '1. <strong>特徵值:</strong> s = σ ± jω<br>' |
| + '2. <strong>阻尼比:</strong> ζ = −σ / √(σ² + ω²)<br>' |
| + '3. <strong>臨界邊界:</strong> σ 越過 0 進入右半平面時 ζ < 0,系統發散。<br>' |
| + '4. <strong>告警閾值:</strong> 極點顏色、狀態與時域曲線均以同一規則判斷:σ > 0 → 危險;0 ≤ ζ < 0.05 → 警告;ζ ≥ 0.05 → 安全。<br>' |
| + '<span class="disclaimer">本頁為手動調參的互動教具,非 PMU / Toeplitz 即時辨識輸出;時域圖僅示範 e<sup>σt</sup>cos(ωt),不含模態振幅與相位。</span>', |
| splaneHeading: '複數平面 (s-plane) 映射', |
| timeHeading: '時域響應(示意波形)', |
| timeCaption: 'y(t) = e<sup>σt</sup> cos(ωt) — 固定單位振幅,僅供直覺理解衰減與頻率', |
| axisRe: 'Re (σ)', |
| axisIm: 'Im (jω)', |
| polePos: 's = σ + jω', |
| poleNeg: 's = σ − jω', |
| collapseWarn: '⚠ 振幅超出顯示範圍' |
| }, |
| en: { |
| pageTitle: 'TaiScience CSD-EWS: Pole & Damping Ratio Simulator', |
| titleMain: 'TaiScience CSD-EWS', |
| titleSub: 'Real-time pole & damping ratio simulator | Jacobian eigenvalue proxy (educational demo)', |
| controlsHeading: 'System Eigenvalue Parameters (Poles)', |
| labelSigma: 'Real part (σ, decay/growth rate):', |
| labelOmega: 'Imaginary part (ω, oscillation frequency):', |
| zetaLabel: 'Damping ratio (ζ):', |
| statusLabel: 'Status (ζ threshold 5%):', |
| statusDanger: 'Unstable / negative damping (σ > 0)', |
| statusWarning: 'Weak damping (ζ < 5%)', |
| statusSafe: 'Adequate damping (ζ ≥ 5%)', |
| mathNotes: '<strong>Core formulas (conjugate-pole assumption):</strong><br><br>' |
| + '1. <strong>Eigenvalues:</strong> s = σ ± jω<br>' |
| + '2. <strong>Damping ratio:</strong> ζ = −σ / √(σ² + ω²)<br>' |
| + '3. <strong>Stability boundary:</strong> when σ crosses 0 into the RHP, ζ < 0 and the mode diverges.<br>' |
| + '4. <strong>Alert rule:</strong> pole color, status, and waveform share one rule: σ > 0 → danger; 0 ≤ ζ < 0.05 → warning; ζ ≥ 0.05 → safe.<br>' |
| + '<span class="disclaimer">This page is a manual teaching demo, not live PMU / Toeplitz identification. The waveform shows e<sup>σt</sup>cos(ωt) only—no modal amplitude or phase.</span>', |
| splaneHeading: 'Complex s-plane map', |
| timeHeading: 'Time-domain response (illustrative)', |
| timeCaption: 'y(t) = e<sup>σt</sup> cos(ωt) — unit amplitude fixed; for intuition only', |
| axisRe: 'Re (σ)', |
| axisIm: 'Im (jω)', |
| polePos: 's = σ + jω', |
| poleNeg: 's = σ − jω', |
| collapseWarn: '⚠ Amplitude exceeds plot range' |
| } |
| }; |
| |
| let lang = localStorage.getItem('csd-sim-lang') || 'zh-TW'; |
| if (!I18N[lang]) lang = 'zh-TW'; |
| |
| const sigmaSlider = document.getElementById('sigma'); |
| const omegaSlider = document.getElementById('omega'); |
| const sigmaVal = document.getElementById('sigma-val'); |
| const omegaVal = document.getElementById('omega-val'); |
| const zetaVal = document.getElementById('zeta-val'); |
| const statusText = document.getElementById('status-text'); |
| const statusBox = document.getElementById('status-box'); |
| const splaneCanvas = document.getElementById('s-plane'); |
| const ctxS = splaneCanvas.getContext('2d'); |
| const timeCanvas = document.getElementById('time-domain'); |
| const ctxT = timeCanvas.getContext('2d'); |
| |
| function t(key) { |
| return I18N[lang][key]; |
| } |
| |
| |
| function getStatus(sigma, zeta) { |
| if (sigma > 0 || zeta < 0) { |
| return { level: 'danger', color: '#ff6b6b', message: t('statusDanger') }; |
| } |
| if (zeta < ZETA_WEAK_THRESHOLD) { |
| return { level: 'warning', color: '#fcc419', message: t('statusWarning') }; |
| } |
| return { level: 'safe', color: '#51cf66', message: t('statusSafe') }; |
| } |
| |
| function setLabelHtml(id, text) { |
| const el = document.getElementById(id); |
| const valSpan = el.querySelector('.val'); |
| const valId = valSpan ? valSpan.id : null; |
| const valText = valSpan ? valSpan.textContent : ''; |
| el.innerHTML = text + (valSpan ? ' <span class="val" id="' + valId + '">' + valText + '</span>' : ''); |
| } |
| |
| function applyLanguage() { |
| const L = I18N[lang]; |
| document.documentElement.lang = lang === 'en' ? 'en' : 'zh-TW'; |
| document.title = L.pageTitle; |
| document.getElementById('title-main').textContent = L.titleMain; |
| document.getElementById('title-sub').textContent = L.titleSub; |
| document.getElementById('controls-heading').textContent = L.controlsHeading; |
| setLabelHtml('label-sigma', L.labelSigma); |
| setLabelHtml('label-omega', L.labelOmega); |
| document.getElementById('zeta-label').textContent = L.zetaLabel; |
| document.getElementById('status-label').textContent = L.statusLabel; |
| document.getElementById('math-notes').innerHTML = L.mathNotes; |
| document.getElementById('splane-heading').textContent = L.splaneHeading; |
| document.getElementById('time-heading').textContent = L.timeHeading; |
| document.getElementById('time-caption').innerHTML = L.timeCaption; |
| document.getElementById('lang-zh').classList.toggle('active', lang === 'zh-TW'); |
| document.getElementById('lang-en').classList.toggle('active', lang === 'en'); |
| document.getElementById('lang-zh').setAttribute('aria-pressed', lang === 'zh-TW'); |
| document.getElementById('lang-en').setAttribute('aria-pressed', lang === 'en'); |
| localStorage.setItem('csd-sim-lang', lang); |
| draw(); |
| } |
| |
| function resizeCanvases() { |
| splaneCanvas.width = splaneCanvas.clientWidth * window.devicePixelRatio; |
| splaneCanvas.height = splaneCanvas.clientHeight * window.devicePixelRatio; |
| timeCanvas.width = timeCanvas.clientWidth * window.devicePixelRatio; |
| timeCanvas.height = timeCanvas.clientHeight * window.devicePixelRatio; |
| ctxS.setTransform(1, 0, 0, 1, 0, 0); |
| ctxT.setTransform(1, 0, 0, 1, 0, 0); |
| ctxS.scale(window.devicePixelRatio, window.devicePixelRatio); |
| ctxT.scale(window.devicePixelRatio, window.devicePixelRatio); |
| draw(); |
| } |
| window.addEventListener('resize', resizeCanvases); |
| |
| function drawSPlane(sigma, omega, poleColor) { |
| const w = splaneCanvas.clientWidth; |
| const h = splaneCanvas.clientHeight; |
| if (w <= 0 || h <= 0) return; |
| ctxS.clearRect(0, 0, w, h); |
| |
| const originX = w * 0.75; |
| const originY = h / 2; |
| |
| ctxS.fillStyle = 'rgba(43, 138, 62, 0.15)'; |
| ctxS.fillRect(0, 0, originX, h); |
| ctxS.fillStyle = 'rgba(201, 42, 42, 0.15)'; |
| ctxS.fillRect(originX, 0, w - originX, h); |
| |
| ctxS.strokeStyle = '#333'; |
| ctxS.lineWidth = 1; |
| ctxS.beginPath(); |
| for (let i = 0; i < w; i += 40) { ctxS.moveTo(i, 0); ctxS.lineTo(i, h); } |
| for (let i = 0; i < h; i += 40) { ctxS.moveTo(0, i); ctxS.lineTo(w, i); } |
| ctxS.stroke(); |
| |
| ctxS.beginPath(); |
| ctxS.moveTo(0, originY); ctxS.lineTo(w, originY); |
| ctxS.moveTo(originX, 0); ctxS.lineTo(originX, h); |
| ctxS.strokeStyle = '#888'; |
| ctxS.lineWidth = 2; |
| ctxS.stroke(); |
| |
| ctxS.fillStyle = '#aaa'; |
| ctxS.font = '12px sans-serif'; |
| ctxS.fillText(t('axisRe'), w - 40, originY - 10); |
| ctxS.fillText(t('axisIm'), originX + 10, 20); |
| |
| const scaleX = 80; |
| const scaleY = 7; |
| const px = originX + sigma * scaleX; |
| const py1 = originY - omega * scaleY; |
| const py2 = originY + omega * scaleY; |
| |
| function drawCross(x, y, color) { |
| ctxS.beginPath(); |
| ctxS.moveTo(x - 6, y - 6); ctxS.lineTo(x + 6, y + 6); |
| ctxS.moveTo(x - 6, y + 6); ctxS.lineTo(x + 6, y - 6); |
| ctxS.strokeStyle = color; |
| ctxS.lineWidth = 3; |
| ctxS.stroke(); |
| } |
| |
| drawCross(px, py1, poleColor); |
| drawCross(px, py2, poleColor); |
| |
| ctxS.fillStyle = '#fff'; |
| ctxS.font = '14px sans-serif'; |
| ctxS.fillText(t('polePos'), px + 15, py1 + 5); |
| ctxS.fillText(t('poleNeg'), px + 15, py2 + 5); |
| |
| ctxS.beginPath(); |
| ctxS.setLineDash([5, 5]); |
| ctxS.moveTo(originX, originY); |
| ctxS.lineTo(px, py1); |
| ctxS.moveTo(originX, originY); |
| ctxS.lineTo(px, py2); |
| ctxS.strokeStyle = poleColor; |
| ctxS.lineWidth = 1.5; |
| ctxS.stroke(); |
| ctxS.setLineDash([]); |
| } |
| |
| function drawTimeDomain(sigma, omega, strokeColor) { |
| const w = timeCanvas.clientWidth; |
| const h = timeCanvas.clientHeight; |
| if (w <= 0 || h <= 0) return; |
| ctxT.clearRect(0, 0, w, h); |
| |
| const originY = h / 2; |
| |
| ctxT.strokeStyle = '#222'; |
| ctxT.lineWidth = 1; |
| ctxT.beginPath(); |
| for (let i = 0; i < w; i += 50) { ctxT.moveTo(i, 0); ctxT.lineTo(i, h); } |
| for (let i = 0; i < h; i += 40) { ctxT.moveTo(0, i); ctxT.lineTo(w, i); } |
| ctxT.stroke(); |
| |
| ctxT.beginPath(); |
| ctxT.moveTo(0, originY); ctxT.lineTo(w, originY); |
| ctxT.strokeStyle = '#666'; |
| ctxT.lineWidth = 2; |
| ctxT.stroke(); |
| |
| const timeDuration = 10; |
| let outOfBounds = false; |
| |
| ctxT.beginPath(); |
| for (let px = 0; px <= w; px++) { |
| const tSec = (px / w) * timeDuration; |
| const y = Math.exp(sigma * tSec) * Math.cos(omega * tSec); |
| const py = originY - (y * (h / 4)); |
| if (py < -h || py > h * 2) outOfBounds = true; |
| if (px === 0) ctxT.moveTo(px, py); |
| else ctxT.lineTo(px, py); |
| } |
| |
| ctxT.strokeStyle = strokeColor; |
| ctxT.lineWidth = 3; |
| ctxT.stroke(); |
| |
| if (outOfBounds) { |
| ctxT.fillStyle = 'rgba(255, 107, 107, 0.8)'; |
| ctxT.font = 'bold 16px sans-serif'; |
| ctxT.fillText(t('collapseWarn'), 20, 30); |
| } |
| } |
| |
| function draw() { |
| const sigma = parseFloat(sigmaSlider.value); |
| const omega = parseFloat(omegaSlider.value); |
| const mag = Math.sqrt(sigma * sigma + omega * omega); |
| const zeta = mag > 0 ? -sigma / mag : 0; |
| const status = getStatus(sigma, zeta); |
| |
| document.getElementById('sigma-val').textContent = sigma.toFixed(2); |
| document.getElementById('omega-val').textContent = omega.toFixed(1); |
| zetaVal.textContent = zeta.toFixed(4); |
| zetaVal.style.color = status.color; |
| |
| statusText.textContent = status.message; |
| statusText.className = status.level; |
| statusBox.style.borderColor = status.color; |
| |
| drawSPlane(sigma, omega, status.color); |
| drawTimeDomain(sigma, omega, status.color); |
| } |
| |
| sigmaSlider.addEventListener('input', draw); |
| omegaSlider.addEventListener('input', draw); |
| |
| document.getElementById('lang-zh').addEventListener('click', () => { |
| if (lang !== 'zh-TW') { lang = 'zh-TW'; applyLanguage(); } |
| }); |
| document.getElementById('lang-en').addEventListener('click', () => { |
| if (lang !== 'en') { lang = 'en'; applyLanguage(); } |
| }); |
| |
| applyLanguage(); |
| requestAnimationFrame(() => { |
| requestAnimationFrame(resizeCanvases); |
| }); |
| </script> |
| </body> |
| </html> |
|
|