| |
| let currentPptId = "ppt1"; |
| let currentSlideIndex = 0; |
| let searchIndex = []; |
|
|
| |
| const menuItems = document.querySelectorAll(".menu-item"); |
| const tabSections = document.querySelectorAll(".tab-section"); |
| const tabTitleEl = document.getElementById("current-tab-title"); |
| const tabSubtitleEl = document.getElementById("current-tab-subtitle"); |
|
|
| const pptSelectEl = document.getElementById("ppt-select"); |
| const slideThumbnailsEl = document.getElementById("slide-thumbnails"); |
| const mainSlideImgEl = document.getElementById("main-slide-img"); |
| const slideIndexIndicatorEl = document.getElementById("slide-index-indicator"); |
| const btnPrevSlide = document.getElementById("btn-prev-slide"); |
| const btnNextSlide = document.getElementById("btn-next-slide"); |
| const slideTextLinesEl = document.getElementById("slide-text-lines"); |
| const slideTakeawayEl = document.getElementById("slide-takeaway-points"); |
| const btnFullscreen = document.getElementById("btn-fullscreen"); |
|
|
| const lightroomEl = document.getElementById("lightroom"); |
| const lightroomImgEl = document.getElementById("lightroom-img"); |
| const btnCloseLightroom = document.getElementById("btn-close-lightroom"); |
|
|
| |
| const sliderDepth = document.getElementById("slider-depth"); |
| const sliderDist = document.getElementById("slider-dist"); |
| const sliderVs = document.getElementById("slider-vs"); |
| const sliderProcess = document.getElementById("slider-process"); |
|
|
| const valDepth = document.getElementById("depth-val"); |
| const valDist = document.getElementById("dist-val"); |
| const valVs = document.getElementById("vs-val"); |
| const valProcess = document.getElementById("process-val"); |
|
|
| const calcLeadTimeEl = document.getElementById("calc-lead-time"); |
| const calcWarningStatusEl = document.getElementById("calc-warning-status"); |
| const txtBlindRadius = document.getElementById("blind-radius-txt"); |
| const txtHypoDist = document.getElementById("hypo-dist-txt"); |
| const blindZoneCanvas = document.getElementById("blind-zone-canvas"); |
|
|
| |
| const searchInput = document.getElementById("search-input"); |
| const searchResults = document.getElementById("search-results"); |
|
|
| |
| const tabMeta = { |
| overview: { title: "知識庫總覽", subtitle: "展示台灣中央氣象署強震即時警報與 AI 技術整合之演進" }, |
| highlights: { title: "簡報精選精華", subtitle: "彙整四大簡報最關鍵的核心圖表與重要原理,支援一鍵定位簡報脈絡" }, |
| viewer: { title: "投影片展示", subtitle: "逐頁瀏覽 4 份核心簡報精華、提取文字與高畫質圖片" }, |
| calculator: { title: "預警盲區模擬", subtitle: "互動式物理參數計算,深入理解應變盲區與警報前置時間的幾何關係" }, |
| swarm: { title: "AI 代理協作", subtitle: "解析 2026 年 CWA 強震即時警報系統中多代理群組自動化閉環工作流" }, |
| lem: { title: "大型地震模型 (LEM)", subtitle: "介紹以 Wav2Vec 2.0 為基座演化的次世代地震自監督深度學習模型 SeisWav2Vec" }, |
| search: { title: "全文大綱搜尋", subtitle: "高效檢索四大簡報共 164 頁的文字內容,快速定位知識點" }, |
| downloads: { title: "簡報下載專區", subtitle: "查閱本地原始 PPTX 簡報大小與詳細檔案路徑資訊" } |
| }; |
|
|
| |
| window.addEventListener("DOMContentLoaded", () => { |
| initNavigation(); |
| initSlideViewer(); |
| initCalculator(); |
| initSwarmVisualizer(); |
| initSearchEngine(); |
| initLightroom(); |
| initHighlights(); |
| }); |
|
|
| |
| function initNavigation() { |
| menuItems.forEach(item => { |
| item.addEventListener("click", () => { |
| const tabId = item.getAttribute("data-tab"); |
| |
| |
| menuItems.forEach(mi => mi.classList.remove("active")); |
| item.classList.add("active"); |
| |
| |
| tabSections.forEach(section => { |
| section.classList.remove("active"); |
| if (section.id === `tab-${tabId}`) { |
| section.classList.add("active"); |
| } |
| }); |
| |
| |
| const meta = tabMeta[tabId] || { title: "展示平台", subtitle: "" }; |
| tabTitleEl.textContent = meta.title; |
| tabSubtitleEl.textContent = meta.subtitle; |
| |
| |
| if (tabId === "calculator") { |
| setTimeout(drawBlindZone, 100); |
| } else if (tabId === "swarm") { |
| setTimeout(drawSwarm, 100); |
| } |
| }); |
| }); |
| } |
|
|
| function jumpToTab(tabId) { |
| const item = document.querySelector(`.menu-item[data-tab="${tabId}"]`); |
| if (item) item.click(); |
| } |
|
|
| function jumpToPresentation(pptId, slideNum = 1) { |
| currentPptId = pptId; |
| pptSelectEl.value = pptId; |
| currentSlideIndex = slideNum - 1; |
| renderSlidesList(); |
| jumpToTab("viewer"); |
| } |
|
|
| |
| function initSlideViewer() { |
| pptSelectEl.addEventListener("change", (e) => { |
| currentPptId = e.target.value; |
| currentSlideIndex = 0; |
| renderSlidesList(); |
| }); |
| |
| btnPrevSlide.addEventListener("click", () => { |
| if (currentSlideIndex > 0) { |
| currentSlideIndex--; |
| updateActiveSlide(); |
| } |
| }); |
| |
| btnNextSlide.addEventListener("click", () => { |
| const pptData = slidesData.find(p => p.id === currentPptId); |
| if (pptData && currentSlideIndex < pptData.slides.length - 1) { |
| currentSlideIndex++; |
| updateActiveSlide(); |
| } |
| }); |
| |
| renderSlidesList(); |
| } |
|
|
| function renderSlidesList() { |
| const pptData = slidesData.find(p => p.id === currentPptId); |
| if (!pptData) return; |
| |
| slideThumbnailsEl.innerHTML = ""; |
| pptData.slides.forEach((slide, idx) => { |
| const thumb = document.createElement("div"); |
| thumb.className = `thumb-item ${idx === currentSlideIndex ? 'active' : ''}`; |
| thumb.setAttribute("data-index", idx); |
| |
| |
| let titleStr = slide.title || (slide.text && slide.text[0]) || `Slide ${slide.slideNumber}`; |
| if (titleStr.length > 22) { |
| titleStr = titleStr.substring(0, 20) + "..."; |
| } |
| |
| thumb.innerHTML = ` |
| <span class="thumb-num">Slide ${slide.slideNumber}</span> |
| <span class="thumb-title" title="${slide.title || ''}">${titleStr}</span> |
| `; |
| |
| thumb.addEventListener("click", () => { |
| currentSlideIndex = idx; |
| updateActiveSlide(); |
| }); |
| |
| slideThumbnailsEl.appendChild(thumb); |
| }); |
| |
| updateActiveSlide(); |
| } |
|
|
| function updateActiveSlide() { |
| const pptData = slidesData.find(p => p.id === currentPptId); |
| if (!pptData) return; |
| |
| const slide = pptData.slides[currentSlideIndex]; |
| if (!slide) return; |
| |
| |
| mainSlideImgEl.src = slide.image; |
| |
| |
| const thumbs = slideThumbnailsEl.querySelectorAll(".thumb-item"); |
| thumbs.forEach((t, idx) => { |
| t.classList.remove("active"); |
| if (idx === currentSlideIndex) { |
| t.classList.add("active"); |
| t.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
| } |
| }); |
| |
| |
| slideIndexIndicatorEl.textContent = `Slide ${slide.slideNumber} / ${pptData.slidesCount}`; |
| |
| |
| btnPrevSlide.disabled = currentSlideIndex === 0; |
| btnNextSlide.disabled = currentSlideIndex === pptData.slides.length - 1; |
| |
| |
| slideTextLinesEl.innerHTML = ""; |
| if (slide.text && slide.text.length > 0) { |
| slide.text.forEach(line => { |
| const li = document.createElement("li"); |
| li.textContent = line; |
| slideTextLinesEl.appendChild(li); |
| }); |
| } else { |
| const li = document.createElement("li"); |
| li.textContent = "(此頁投影片無主要大綱文字,可能為全圖表展示)"; |
| li.style.color = "var(--text-dark)"; |
| li.style.fontStyle = "italic"; |
| slideTextLinesEl.appendChild(li); |
| } |
| |
| |
| slideTakeawayEl.innerHTML = ""; |
| const takeaways = generateTakeaways(slide, pptData.id); |
| takeaways.forEach(p => { |
| const li = document.createElement("li"); |
| li.textContent = p; |
| slideTakeawayEl.appendChild(li); |
| }); |
| } |
|
|
| function generateTakeaways(slide, pptId) { |
| const points = []; |
| const textJoined = (slide.text || []).join(" ").toLowerCase(); |
| const title = (slide.title || "").toLowerCase(); |
| |
| if (pptId === "ppt1") { |
| if (textJoined.includes("earthworm")) points.push("Earthworm:USGS 開源地震處理模組化系統,為全球實時監控基準。"); |
| if (textJoined.includes("seiscomp")) points.push("SeisComP:先進地震圖形界面與定位波相拾取平台。"); |
| if (textJoined.includes("grafana")) points.push("Grafana 觀測:即時視覺化海底觀測網水壓計、波形延遲及預警推播狀態。"); |
| if (textJoined.includes("ebear")) points.push("eBEAR:CWA 自行開發的 Earthworm Alert Reporting 自動速報系統。"); |
| if (textJoined.includes("docker")) points.push("容器化部署:CWA EEW 與 Seiscomp 已打包為 Docker 映像檔上傳 Docker Hub。"); |
| } else if (pptId === "ppt2") { |
| if (textJoined.includes("pws")) points.push("PWS:災防告警細胞廣播發布範疇統計(如 M6.1 地震第一報與第二報範圍)。"); |
| if (textJoined.includes("幾何中心")) points.push("定位法:幾何中心法在早期預警解算中對震央與震度有誤差修正之探討。"); |
| if (textJoined.includes("盲區") || textJoined.includes("應變")) points.push("應變盲區:最快解算耗時乘以 S 波平均波速(3.5 km/s),該範圍內無警報前置時間。"); |
| if (textJoined.includes("觸發")) points.push("測站觸發率:針對特定震源以半徑搜索,排除共站干擾計算實時觸發比例。"); |
| } else if (pptId === "ppt3") { |
| if (textJoined.includes("agent") || textJoined.includes("代理")) points.push("AI Agent Swarm:my_agent, seismo, seismo_agent, secondary_agent 四協同代理職責分工。"); |
| if (textJoined.includes("wav2vec") || textJoined.includes("lem")) points.push("SeisWav2Vec 2.0:對比學習 Wav2Vec 結構遷移至地震波形,自監督預訓練模型。"); |
| if (textJoined.includes("transformer")) points.push("Transformer 上下文:12 層注意力機制學習特徵的全局上下文,無標記資料自學習。"); |
| if (textJoined.includes("telegram")) points.push("Telegram 通訊:整合 my_agent 自動推送 eew_health、異常日誌及 daily_report。"); |
| } else if (pptId === "ppt4") { |
| if (textJoined.includes("p波") || textJoined.includes("s波")) points.push("強震預警原理:利用傳播快速的 P 縱波推算震級,趕在破壞性 S 橫波抵達前告警。"); |
| if (textJoined.includes("電視") || textJoined.includes("推播")) points.push("多元傳播管道:國家級警報、電視台即時插播、中小學預警軟體及手機 APP 四大網絡。"); |
| if (textJoined.includes("花蓮")) points.push("花蓮 M7.2 案例:回顧強震預警警報實際發送實例、各縣市震度與警報時效。"); |
| } |
| |
| if (points.length === 0) { |
| points.push("地震預警核心知識:解析地震波傳播時序與即時資料串流。"); |
| points.push("技術重點:結合自動化解算模型,縮減預警盲區以求減災。"); |
| } |
| |
| return points; |
| } |
|
|
| |
| function initLightroom() { |
| mainSlideImgEl.addEventListener("click", () => { |
| lightroomImgEl.src = mainSlideImgEl.src; |
| lightroomEl.style.display = "flex"; |
| }); |
| |
| lightroomEl.addEventListener("click", () => { |
| lightroomEl.style.display = "none"; |
| }); |
| |
| btnCloseLightroom.addEventListener("click", (e) => { |
| e.stopPropagation(); |
| lightroomEl.style.display = "none"; |
| }); |
| } |
|
|
| |
| function initCalculator() { |
| const updateCalculator = () => { |
| const depth = parseFloat(sliderDepth.value); |
| const dist = parseFloat(sliderDist.value); |
| const vs = parseFloat(sliderVs.value); |
| const processTime = parseFloat(sliderProcess.value); |
| |
| |
| valDepth.textContent = `${depth} km`; |
| valDist.textContent = `${dist} km`; |
| valVs.textContent = `${vs.toFixed(1)} km/s`; |
| valProcess.textContent = `${processTime.toFixed(1)} s`; |
| |
| |
| const hypoDist = Math.sqrt(dist*dist + depth*depth); |
| const sTravelTime = hypoDist / vs; |
| const leadTime = sTravelTime - processTime; |
| |
| |
| |
| |
| let blindRadius = 0; |
| const sPath = processTime * vs; |
| if (sPath > depth) { |
| blindRadius = Math.sqrt(sPath*sPath - depth*depth); |
| } |
| |
| |
| txtHypoDist.textContent = `${hypoDist.toFixed(1)} km`; |
| txtBlindRadius.textContent = blindRadius > 0 ? `${blindRadius.toFixed(1)} km` : "0 km (未傳抵地表)"; |
| |
| if (leadTime > 0) { |
| calcLeadTimeEl.innerHTML = `${leadTime.toFixed(1)}<span>秒</span>`; |
| calcLeadTimeEl.style.color = "var(--accent-cyan)"; |
| calcWarningStatusEl.textContent = "警報覆蓋安全區"; |
| calcWarningStatusEl.className = "warning-status-alert status-safe"; |
| } else { |
| calcLeadTimeEl.innerHTML = `0.0<span>秒</span>`; |
| calcLeadTimeEl.style.color = "var(--accent-rose)"; |
| calcWarningStatusEl.textContent = "處於預警盲區內"; |
| calcWarningStatusEl.className = "warning-status-alert status-blind"; |
| } |
| |
| |
| window.calcState = { depth, dist, vs, processTime, hypoDist, blindRadius, leadTime }; |
| drawBlindZone(); |
| }; |
| |
| sliderDepth.addEventListener("input", updateCalculator); |
| sliderDist.addEventListener("input", updateCalculator); |
| sliderVs.addEventListener("input", updateCalculator); |
| sliderProcess.addEventListener("input", updateCalculator); |
| |
| updateCalculator(); |
| } |
|
|
| function drawBlindZone() { |
| const canvas = blindZoneCanvas; |
| if (!canvas) return; |
| const ctx = canvas.getContext("2d"); |
| |
| |
| const dpr = window.devicePixelRatio || 1; |
| const rect = canvas.getBoundingClientRect(); |
| canvas.width = rect.width * dpr; |
| canvas.height = rect.height * dpr; |
| ctx.scale(dpr, dpr); |
| |
| const w = rect.width; |
| const h = rect.height; |
| ctx.clearRect(0, 0, w, h); |
| |
| const state = window.calcState; |
| if (!state) return; |
| |
| |
| |
| const ySurface = 80; |
| |
| const xEpicenter = w / 2 - 80; |
| |
| |
| const scale = 2.0; |
| |
| |
| ctx.fillStyle = "rgba(10, 15, 26, 0.4)"; |
| ctx.fillRect(0, ySurface, w, h - ySurface); |
| |
| |
| ctx.strokeStyle = "#475569"; |
| ctx.lineWidth = 2; |
| ctx.beginPath(); |
| ctx.moveTo(0, ySurface); |
| ctx.lineTo(w, ySurface); |
| ctx.stroke(); |
| |
| |
| ctx.fillStyle = "#94a3b8"; |
| ctx.beginPath(); |
| ctx.arc(xEpicenter, ySurface, 4, 0, 2*Math.PI); |
| ctx.fill(); |
| |
| |
| const yFocus = ySurface + state.depth * scale; |
| ctx.fillStyle = "var(--accent-rose)"; |
| ctx.beginPath(); |
| ctx.arc(xEpicenter, yFocus, 6, 0, 2*Math.PI); |
| ctx.fill(); |
| |
| |
| const gradientFocus = ctx.createRadialGradient(xEpicenter, yFocus, 2, xEpicenter, yFocus, 18); |
| gradientFocus.addColorStop(0, "rgba(244, 63, 94, 0.6)"); |
| gradientFocus.addColorStop(1, "rgba(244, 63, 94, 0)"); |
| ctx.fillStyle = gradientFocus; |
| ctx.beginPath(); |
| ctx.arc(xEpicenter, yFocus, 18, 0, 2*Math.PI); |
| ctx.fill(); |
| |
| |
| const xStation = xEpicenter + state.dist * scale; |
| ctx.fillStyle = state.leadTime > 0 ? "var(--accent-cyan)" : "var(--accent-rose)"; |
| ctx.beginPath(); |
| ctx.arc(xStation, ySurface, 5, 0, 2*Math.PI); |
| ctx.fill(); |
| |
| |
| ctx.fillStyle = "white"; |
| ctx.font = "bold 11px sans-serif"; |
| ctx.fillText("目標觀測點", xStation - 25, ySurface - 12); |
| |
| |
| |
| const rSwave = state.processTime * state.vs * scale; |
| ctx.strokeStyle = "rgba(245, 158, 11, 0.4)"; |
| ctx.lineWidth = 1.5; |
| ctx.setLineDash([4, 3]); |
| ctx.beginPath(); |
| ctx.arc(xEpicenter, yFocus, rSwave, 0, 2*Math.PI); |
| ctx.stroke(); |
| ctx.setLineDash([]); |
| |
| |
| const rPwave = state.processTime * state.vs * 1.73 * scale; |
| ctx.strokeStyle = "rgba(6, 182, 212, 0.25)"; |
| ctx.lineWidth = 1; |
| ctx.beginPath(); |
| ctx.arc(xEpicenter, yFocus, rPwave, 0, 2*Math.PI); |
| ctx.stroke(); |
| |
| |
| const rBlindPx = state.blindRadius * scale; |
| if (rBlindPx > 0) { |
| ctx.fillStyle = "rgba(244, 63, 94, 0.15)"; |
| ctx.fillRect(xEpicenter - rBlindPx, ySurface, rBlindPx * 2, h - ySurface); |
| |
| |
| ctx.strokeStyle = "var(--accent-rose)"; |
| ctx.lineWidth = 3; |
| ctx.beginPath(); |
| ctx.moveTo(xEpicenter - rBlindPx, ySurface); |
| ctx.lineTo(xEpicenter + rBlindPx, ySurface); |
| ctx.stroke(); |
| |
| |
| ctx.fillStyle = "var(--accent-rose)"; |
| ctx.font = "bold 11px sans-serif"; |
| ctx.fillText(`預警盲區半徑: ${state.blindRadius.toFixed(1)} km`, xEpicenter - 65, ySurface + 24); |
| } |
| |
| |
| ctx.fillStyle = "var(--accent-amber)"; |
| const intersectDist = state.processTime * state.vs; |
| if (intersectDist > state.depth) { |
| const xIntersectLeft = xEpicenter - rBlindPx; |
| const xIntersectRight = xEpicenter + rBlindPx; |
| ctx.beginPath(); |
| ctx.arc(xIntersectLeft, ySurface, 4, 0, 2*Math.PI); |
| ctx.arc(xIntersectRight, ySurface, 4, 0, 2*Math.PI); |
| ctx.fill(); |
| } |
| |
| |
| ctx.strokeStyle = "rgba(255, 255, 255, 0.15)"; |
| ctx.lineWidth = 1; |
| ctx.beginPath(); |
| ctx.moveTo(xEpicenter, yFocus); |
| ctx.lineTo(xStation, ySurface); |
| ctx.stroke(); |
| |
| |
| ctx.fillStyle = "var(--text-dark)"; |
| ctx.font = "10px sans-serif"; |
| ctx.fillText("震央 (Epicenter)", xEpicenter + 8, ySurface - 12); |
| |
| ctx.fillStyle = "var(--accent-rose)"; |
| ctx.fillText(`震源 (Hypocenter) Depth: ${state.depth} km`, xEpicenter + 12, yFocus + 4); |
| } |
|
|
| |
| function initSwarmVisualizer() { |
| const swarmCanvas = document.getElementById("agent-swarm-canvas"); |
| if (!swarmCanvas) return; |
| |
| |
| const agentNodes = [ |
| { id: "my_agent", name: "my_agent", role: "預警總管", color: "var(--accent-emerald)", bg: "var(--accent-emerald-glow)", x: 200, y: 120, r: 48, desc: "主控監測流程,定時健康檢查 (eew_health cron)。判讀並處理異常事件,異常時透過 Telegram 主動發送告警,並彙整所有 agent 資訊產出每日簡報日報 (eew_report_daily)。" }, |
| { id: "seismo", name: "seismo", role: "數據前向轉發", color: "var(--accent-blue)", bg: "var(--accent-blue-glow)", x: 450, y: 120, r: 42, desc: "維持持續運行的心跳循環 (heartbeat loop),即時偵測及提取中央氣象署 EEW 流程產生的最新地震 `.rep` 報告原始檔,將資料進行結構化轉換輸出 (轉為 JSON/CSV)。" }, |
| { id: "seismo_agent", name: "seismo_agent", role: "地震學分析助教", color: "var(--accent-amber)", bg: "var(--accent-amber-glow)", x: 450, y: 320, r: 42, desc: "負責科學數據分析與處理流水線。解析速報效能與測站延遲統計,呼叫自動繪圖模組繪製事件散佈圖與序列圖,同時儲存備份二進位原始數據檔。" }, |
| { id: "secondary_agent", name: "secondary_agent", role: "系統整合工程師", color: "var(--accent-rose)", bg: "var(--accent-rose-glow)", x: 200, y: 320, r: 48, desc: "本地調度與訊息轉發中心。負責部署與維護容器重啟腳本 (eew_fault_recovery.sh)、Cron 排程及 Telegram 機器人核心,負責在 my_agent 要求時調用 delegate_task 指派任務。" } |
| ]; |
| |
| const links = [ |
| { from: "seismo", to: "secondary_agent", label: "地震.rep JSON 輸出" }, |
| { from: "secondary_agent", to: "seismo_agent", label: "分發科研分析與繪圖" }, |
| { from: "seismo_agent", to: "my_agent", label: "回傳延遲時效日報" }, |
| { from: "my_agent", to: "secondary_agent", label: "調度容器故障重啟" }, |
| { from: "seismo", to: "my_agent", label: "心跳狀態報告" } |
| ]; |
| |
| let selectedNodeId = "my_agent"; |
| |
| const updateSwarmOverlay = (node) => { |
| document.getElementById("agent-overlay-name").textContent = node.name; |
| document.getElementById("agent-overlay-role").textContent = node.role; |
| document.getElementById("agent-overlay-role").style.backgroundColor = node.bg; |
| document.getElementById("agent-overlay-role").style.color = node.color; |
| document.getElementById("agent-overlay-desc").textContent = node.desc; |
| document.getElementById("agent-overlay-icon").style.color = node.color; |
| document.getElementById("agent-overlay-icon").style.backgroundColor = node.bg; |
| }; |
| |
| window.drawSwarm = () => { |
| const dpr = window.devicePixelRatio || 1; |
| const rect = swarmCanvas.getBoundingClientRect(); |
| swarmCanvas.width = rect.width * dpr; |
| swarmCanvas.height = rect.height * dpr; |
| const ctx = swarmCanvas.getContext("2d"); |
| ctx.scale(dpr, dpr); |
| |
| const w = rect.width; |
| const h = rect.height; |
| ctx.clearRect(0, 0, w, h); |
| |
| |
| const xCenter = w / 2; |
| const yCenter = h / 2 - 40; |
| |
| |
| agentNodes[0].x = xCenter - 150; agentNodes[0].y = yCenter - 80; |
| agentNodes[1].x = xCenter + 150; agentNodes[1].y = yCenter - 80; |
| agentNodes[2].x = xCenter + 150; agentNodes[2].y = yCenter + 120; |
| agentNodes[3].x = xCenter - 150; agentNodes[3].y = yCenter + 120; |
| |
| |
| links.forEach(l => { |
| const fromNode = agentNodes.find(n => n.id === l.from); |
| const toNode = agentNodes.find(n => n.id === l.to); |
| if (!fromNode || !toNode) return; |
| |
| ctx.strokeStyle = "rgba(148, 163, 184, 0.15)"; |
| ctx.lineWidth = 2; |
| ctx.beginPath(); |
| ctx.moveTo(fromNode.x, fromNode.y); |
| ctx.lineTo(toNode.x, toNode.y); |
| ctx.stroke(); |
| |
| |
| const speed = 0.001; |
| const t = (Date.now() * speed) % 1; |
| const dotX = fromNode.x + (toNode.x - fromNode.x) * t; |
| const dotY = fromNode.y + (toNode.y - fromNode.y) * t; |
| ctx.fillStyle = "var(--accent-cyan)"; |
| ctx.beginPath(); |
| ctx.arc(dotX, dotY, 4, 0, 2*Math.PI); |
| ctx.fill(); |
| }); |
| |
| |
| agentNodes.forEach(node => { |
| const isSelected = node.id === selectedNodeId; |
| |
| |
| if (isSelected) { |
| ctx.shadowColor = node.color; |
| ctx.shadowBlur = 15; |
| } |
| |
| |
| ctx.fillStyle = "rgba(10, 15, 26, 0.9)"; |
| ctx.strokeStyle = isSelected ? "white" : node.color; |
| ctx.lineWidth = isSelected ? 3 : 1.5; |
| ctx.beginPath(); |
| ctx.arc(node.x, node.y, node.r, 0, 2*Math.PI); |
| ctx.fill(); |
| ctx.stroke(); |
| |
| |
| ctx.shadowBlur = 0; |
| |
| |
| ctx.fillStyle = "white"; |
| ctx.font = "bold 12px sans-serif"; |
| ctx.textAlign = "center"; |
| ctx.fillText(node.name, node.x, node.y - 4); |
| |
| ctx.fillStyle = node.color; |
| ctx.font = "10px sans-serif"; |
| ctx.fillText(node.role, node.x, node.y + 14); |
| }); |
| }; |
| |
| |
| swarmCanvas.addEventListener("click", (e) => { |
| const rect = swarmCanvas.getBoundingClientRect(); |
| const x = e.clientX - rect.left; |
| const y = e.clientY - rect.top; |
| |
| |
| for (let node of agentNodes) { |
| const dist = Math.sqrt((x - node.x)**2 + (y - node.y)**2); |
| if (dist <= node.r) { |
| selectedNodeId = node.id; |
| updateSwarmOverlay(node); |
| drawSwarm(); |
| break; |
| } |
| } |
| }); |
| |
| |
| let animId; |
| const loop = () => { |
| if (document.getElementById("tab-swarm").classList.contains("active")) { |
| drawSwarm(); |
| } |
| animId = requestAnimationFrame(loop); |
| }; |
| loop(); |
| |
| |
| updateSwarmOverlay(agentNodes[0]); |
| } |
|
|
| |
| function initSearchEngine() { |
| |
| searchIndex = []; |
| slidesData.forEach(ppt => { |
| ppt.slides.forEach(slide => { |
| searchIndex.push({ |
| pptId: ppt.id, |
| pptTitle: ppt.title, |
| slideNumber: slide.slideNumber, |
| title: slide.title || "", |
| textLines: slide.text || [], |
| image: slide.image |
| }); |
| }); |
| }); |
| |
| searchInput.addEventListener("input", (e) => { |
| const query = e.target.value.toLowerCase().trim(); |
| if (!query) { |
| searchResults.innerHTML = ` |
| <div class="no-results-view"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/> |
| </svg> |
| <p style="font-size: 14px; font-weight: 600;">請輸入關鍵字以檢索四大簡報共 164 頁的文字大綱</p> |
| </div> |
| `; |
| return; |
| } |
| |
| const matches = searchIndex.filter(item => { |
| const matchTitle = item.title.toLowerCase().includes(query); |
| const matchPptTitle = item.pptTitle.toLowerCase().includes(query); |
| const matchLines = item.textLines.some(line => line.toLowerCase().includes(query)); |
| return matchTitle || matchPptTitle || matchLines; |
| }); |
| |
| if (matches.length === 0) { |
| searchResults.innerHTML = ` |
| <div class="no-results-view"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| <circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/> |
| </svg> |
| <p style="font-size: 14px; font-weight: 600;">未找到任何相符的投影片內容</p> |
| <p style="font-size: 12px; color: var(--text-dark); margin-top: 4px;">請試著搜尋其他字眼(如:Earthworm, Grafana, 盲區, 代理)</p> |
| </div> |
| `; |
| return; |
| } |
| |
| searchResults.innerHTML = ""; |
| matches.forEach(item => { |
| const card = document.createElement("div"); |
| card.className = "glass-panel card-padding search-result-card"; |
| |
| |
| let snippet = ""; |
| const matchingLine = item.textLines.find(line => line.toLowerCase().includes(query)); |
| if (matchingLine) { |
| snippet = matchingLine; |
| } else if (item.textLines.length > 0) { |
| snippet = item.textLines[0]; |
| } |
| |
| |
| const queryIdx = snippet.toLowerCase().indexOf(query); |
| if (queryIdx !== -1 && query.length > 0) { |
| const start = snippet.substring(0, queryIdx); |
| const match = snippet.substring(queryIdx, queryIdx + query.length); |
| const end = snippet.substring(queryIdx + query.length); |
| snippet = `${start}<mark style="background-color: rgba(6, 182, 212, 0.4); color: white; border-radius: 2px; padding: 0 2px;">${match}</mark>${end}`; |
| } |
| |
| card.innerHTML = ` |
| <div class="result-image-box"> |
| <img src="${item.image}" alt="Slide Thumb"> |
| </div> |
| <div class="result-content-box"> |
| <div class="result-meta">${item.pptTitle} · 第 ${item.slideNumber} 頁</div> |
| <div class="result-title">${item.title || '(無標題)'}</div> |
| <div class="result-snippet">${snippet}</div> |
| </div> |
| `; |
| |
| card.addEventListener("click", () => { |
| jumpToPresentation(item.pptId, item.slideNumber); |
| }); |
| |
| searchResults.appendChild(card); |
| }); |
| }); |
| } |
|
|
| |
| const highlightsData = [ |
| { |
| pptId: "ppt1", |
| slideNum: 6, |
| category: "network", |
| title: "中央氣象署即時地震觀測網分佈", |
| description: "展示臺灣陸地與海域鋪設的極其密集的觀測網。包含強震儀站點與鋪設於東部海域、南部海域的 OBS 海底電纜觀測系統,是提供即時測報之根本基礎。", |
| keyPoints: ["島內密集陸地強震儀分佈", "東部與南部海纜觀測系統 (OBS)", "即時連續訊號在發震數秒內傳達中心"] |
| }, |
| { |
| pptId: "ppt1", |
| slideNum: 11, |
| category: "network", |
| title: "實時地震波形歸檔整合數據流", |
| description: "展示 Earthworm 處理器如何透過共享記憶體 RING,將 Field Stations 的資料利用 tbuf2mseed 模組壓縮,並同步保存至本機 NAS,確保高可靠度與極低資料延遲。", |
| keyPoints: ["共享記憶體 (Shared Memory) 機制", "支援跨網絡與跨節點資料交換", "Docker 容器化保證部署彈性"] |
| }, |
| { |
| pptId: "ppt1", |
| slideNum: 12, |
| category: "network", |
| title: "eBEAR 自動化強震即時警報軟體", |
| description: "CWA 自主開發的 eBEAR (Earthworm Based Earthquake Alert Reporting) 系統,已封裝成 Docker Image 並提供在 Docker Hub 下載,具備模組化警報回報流程。", |
| keyPoints: ["Docker Hub: cwadayi/earthworm_ubuntu22.04_eew:v1", "高整合性的地震波相拾取與規模演算", "支援自動化的警報派遣與日誌紀錄"] |
| }, |
| { |
| pptId: "ppt2", |
| slideNum: 2, |
| category: "eew", |
| title: "2026年5月 強震即時警報系統 (EEW) 效能", |
| description: "彙整 2026 年 5 月份 EEW 的統計數據。最快解算時效達到驚人的 9.7 秒,PWS 細胞廣播發送 6 次,展示了防減災技術在即時解算上的優異成果。", |
| keyPoints: ["5月份地震預警最速時效達 9.7 秒", "PWS 發布 6 次,電視台即時插播 6 次", "對外通報管道觸發穩定,無漏報異常"] |
| }, |
| { |
| pptId: "ppt2", |
| slideNum: 14, |
| category: "eew", |
| title: "應變盲區 (Blind Zone) 與 S 波幾何學", |
| description: "地震發生後,P 波與 S 波以球形向外傳播。在系統花費時間解算並發送警報前,S 波已波及的範圍(半徑 = 解算時間 × S波速度)即為預警盲區,此區域內前置時間為零。", |
| keyPoints: ["盲區半徑 = 解算時間 (s) × S波速度 (3.5km/s)", "極淺地震(如大埔或美濃)的震央周圍多位於盲區內", "加速測站密集度與算法能有效縮小盲區"] |
| }, |
| { |
| pptId: "ppt2", |
| slideNum: 15, |
| category: "eew", |
| title: "測站觸發率 (Trigger Rate) 半徑篩選", |
| description: "以定位震央為中心、事件最遠觸發測站為半徑作圓,評估該圓形範圍內觀測網測站觸發率,排除共站造成的比率偏低誤差,是評估觀測網效能的關鍵指標。", |
| keyPoints: ["速報震央為圓心,最遠觸發測站為半徑作圓", "剔除重複計算共站,反映真實觀測網覆蓋", "輔助評估密集強震時之儀器健康度"] |
| }, |
| { |
| pptId: "ppt3", |
| slideNum: 4, |
| category: "history", |
| title: "臺灣過去 10 年災害性地震損失統計", |
| description: "展示臺灣過去 10 年內數次引發嚴重災情之強震(包括美濃、花蓮、池上、大埔地震等),凸顯了台灣極高地震風險性與建立次世代預警系統的迫切性。", |
| keyPoints: ["美濃地震維冠大樓塌陷、花蓮地震統帥飯店受災", "高鐵與捷運在預警下成功採取減速避災措施", "災害地震平均每 30-40 年發生一次"] |
| }, |
| { |
| pptId: "ppt3", |
| slideNum: 44, |
| category: "ai", |
| title: "大型地震模型 LEM 與 SeisWav2Vec 2.0", |
| description: "介紹將 Wav2Vec 2.0 語音自監督模型遷移至地震波形特徵提取之架構。由一維 CNN 編碼器與 12 層 Transformer 構成,從無標記波形中自主學習震波語意特徵。", |
| keyPoints: ["自監督學習 (SSL) 無需人工標註海量資料", "Wav2Vec 離散向量量化與 Mask 遮罩學習", "微調後於波相辨識精度與噪訊過濾上顯著提升"] |
| }, |
| { |
| pptId: "ppt3", |
| slideNum: 50, |
| category: "ai", |
| title: "EEW Swarm 四位 Agent 角色與職責矩陣", |
| description: "次世代地震系統中四個協同 Agents 的核心分工矩陣:包含主助理、科學前向轉發工程師、地震分析學助教與本地排程容器整合工程師,建構多代理人體系。", |
| keyPoints: ["my_agent、seismo、seismo_agent、secondary_agent", "實現跨系統的多代理協調派遣", "個別 Agent 維持獨立的指令執行環境與日誌監控"] |
| }, |
| { |
| pptId: "ppt3", |
| slideNum: 54, |
| category: "ai", |
| title: "EEW 代理群多模組自動化閉環工作流", |
| description: "當系統產出新事件的 `.rep` 報告後,自動觸發 seismo 解析資料、seismo_agent 繪製散佈圖、secondary_agent 協調排程、最後由 my_agent 生成簡報推播至 Telegram 的自動化流向圖。", |
| keyPoints: ["事件驅動 (Event-driven) 觸發運行", "Telegram API 即時回報與 eew_fault_recovery 恢復功能", "全流程無人工介入,秒級產出簡報與日報"] |
| }, |
| { |
| pptId: "ppt4", |
| slideNum: 18, |
| category: "eew", |
| title: "強震即時警報物理原理 (P/S 波速差)", |
| description: "圖解地震產生之 P 縱波 (速度快) 與 S 橫波 (破壞大、速度慢) 的傳播物理機制。利用兩者速度差,可在 S 波抵達前數秒解算位置並以電磁波速度搶先警報。", |
| keyPoints: ["P波波速約 6 km/s,S波波速約 3.5 km/s", "電磁波傳輸警報以光速前進,遠快於地震波", "提供關鍵數秒至數十秒避難避險前置時間"] |
| }, |
| { |
| pptId: "ppt4", |
| slideNum: 21, |
| category: "history", |
| title: "2024/04/03 M7.2 花蓮大地震國家級警報實況", |
| description: "回顧花蓮強震當日預警系統運行狀況,說明系統於極短時間內完成解算,並透過細胞廣播 (PWS)、電視插播與中小學防震系統即時推送警報的實際情境。", |
| keyPoints: ["全台多個縣市獲得寶貴避險前置時間", "電視台實時彈出插播覆蓋全螢幕", "分析極震區盲區覆蓋以及大眾運輸系統防災減速反應"] |
| } |
| ]; |
|
|
| function initHighlights() { |
| const container = document.getElementById("highlights-container"); |
| const filterButtons = document.querySelectorAll(".filter-btn"); |
| if (!container) return; |
|
|
| const renderHighlights = (catFilter) => { |
| container.innerHTML = ""; |
| |
| const filtered = catFilter === "all" |
| ? highlightsData |
| : highlightsData.filter(h => h.category === catFilter); |
| |
| filtered.forEach(h => { |
| const card = document.createElement("div"); |
| card.className = "glass-panel"; |
| card.style.display = "flex"; |
| card.style.flexDirection = "column"; |
| card.style.overflow = "hidden"; |
| |
| const slideImgPath = `slides/${h.pptId}/slide_${h.slideNum}.png`; |
| |
| |
| const catLabels = { network: "觀測架構", eew: "預警效能", ai: "AI與代理人", history: "地震與案例" }; |
| const catLabel = catLabels[h.category] || h.category; |
| |
| card.innerHTML = ` |
| <div style="position: relative; width: 100%; padding-top: 56.25%; background: #020408; border-bottom: 1px solid var(--card-border); overflow: hidden; display: flex; align-items: center; justify-content: center;"> |
| <img src="${slideImgPath}" alt="${h.title}" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; cursor: zoom-in; padding: 4px;" class="highlight-img-click"> |
| <span style="position: absolute; top: 12px; left: 12px; background: rgba(10, 15, 26, 0.85); border: 1px solid var(--card-border); padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 700; color: var(--accent-cyan); font-family: var(--font-mono);"> |
| ${h.pptId.toUpperCase()} · Slide ${h.slideNum} |
| </span> |
| <span style="position: absolute; bottom: 12px; right: 12px; background: rgba(59, 130, 246, 0.15); border: 1px solid rgba(59, 130, 246, 0.2); padding: 3px 8px; border-radius: 4px; font-size: 10.5px; font-weight: bold; color: #60a5fa;"> |
| ${catLabel} |
| </span> |
| </div> |
| <div style="padding: 20px; flex: 1; display: flex; flex-direction: column; gap: 12px;"> |
| <h4 style="font-size: 15px; font-weight: 800; color: white; line-height: 1.4;">${h.title}</h4> |
| <p style="font-size: 12.5px; color: var(--text-muted); line-height: 1.6; flex: 1;">${h.description}</p> |
| <ul style="list-style: none; margin-top: 6px; display: flex; flex-direction: column; gap: 6px;"> |
| ${h.keyPoints.map(pt => ` |
| <li style="font-size: 12px; color: var(--text-dark); position: relative; padding-left: 14px; line-height: 1.5;"> |
| <span style="position: absolute; left: 0; top: 5px; width: 4px; height: 4px; border-radius: 50%; background-color: var(--accent-cyan);"></span> |
| ${pt} |
| </li> |
| `).join("")} |
| </ul> |
| </div> |
| <div style="border-top: 1px solid rgba(255, 255, 255, 0.05); padding: 12px 20px; display: flex; justify-content: flex-end; background: rgba(8, 12, 20, 0.2);"> |
| <button class="view-btn jump-to-slide-btn" data-ppt="${h.pptId}" data-slide="${h.slideNum}"> |
| 定位至簡報 |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg> |
| </button> |
| </div> |
| `; |
| |
| |
| card.querySelector(".highlight-img-click").addEventListener("click", () => { |
| lightroomImgEl.src = slideImgPath; |
| lightroomEl.style.display = "flex"; |
| }); |
| |
| |
| card.querySelector(".jump-to-slide-btn").addEventListener("click", () => { |
| jumpToPresentation(h.pptId, h.slideNum); |
| }); |
| |
| container.appendChild(card); |
| }); |
| }; |
|
|
| |
| filterButtons.forEach(btn => { |
| btn.addEventListener("click", () => { |
| filterButtons.forEach(b => { |
| b.classList.remove("active-filter"); |
| b.style.background = ""; |
| b.style.borderColor = ""; |
| }); |
| |
| btn.classList.add("active-filter"); |
| btn.style.background = "var(--accent-cyan-glow)"; |
| btn.style.borderColor = "var(--accent-cyan)"; |
| |
| const filterCat = btn.getAttribute("data-filter-cat"); |
| renderHighlights(filterCat); |
| }); |
| }); |
|
|
| |
| renderHighlights("all"); |
| } |
|
|
|
|