agy / index.js
Nanny7's picture
feat: initial clean LFS commit of showcase
bbfeecb
Raw
History Blame Contribute Delete
39.1 kB
// Application Entry State
let currentPptId = "ppt1";
let currentSlideIndex = 0; // 0-indexed index inside the selected PPT's slides list
let searchIndex = [];
// DOM Elements
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");
// Calculator inputs
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");
// Search inputs
const searchInput = document.getElementById("search-input");
const searchResults = document.getElementById("search-results");
// Tab configuration
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 簡報大小與詳細檔案路徑資訊" }
};
// Initialize Application
window.addEventListener("DOMContentLoaded", () => {
initNavigation();
initSlideViewer();
initCalculator();
initSwarmVisualizer();
initSearchEngine();
initLightroom();
initHighlights();
});
// 1. Navigation Controller
function initNavigation() {
menuItems.forEach(item => {
item.addEventListener("click", () => {
const tabId = item.getAttribute("data-tab");
// Update sidebar state
menuItems.forEach(mi => mi.classList.remove("active"));
item.classList.add("active");
// Update viewport state
tabSections.forEach(section => {
section.classList.remove("active");
if (section.id === `tab-${tabId}`) {
section.classList.add("active");
}
});
// Update header titles
const meta = tabMeta[tabId] || { title: "展示平台", subtitle: "" };
tabTitleEl.textContent = meta.title;
tabSubtitleEl.textContent = meta.subtitle;
// Special canvas re-draw on tab visibility
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");
}
// 2. Slide Showcase Controller
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);
// Find slide title or summary
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;
// Update main display image
mainSlideImgEl.src = slide.image;
// Highlight active thumbnail and scroll into view smoothly
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' });
}
});
// Update indicators
slideIndexIndicatorEl.textContent = `Slide ${slide.slideNumber} / ${pptData.slidesCount}`;
// Update disabled buttons
btnPrevSlide.disabled = currentSlideIndex === 0;
btnNextSlide.disabled = currentSlideIndex === pptData.slides.length - 1;
// Render slide texts
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);
}
// Generate key terms or takeaways dynamically based on slide content
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;
}
// 3. Lightroom Lightbox Modal
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";
});
}
// 4. Blind Zone & Lead Time Warning Simulator
function initCalculator() {
const updateCalculator = () => {
const depth = parseFloat(sliderDepth.value);
const dist = parseFloat(sliderDist.value);
const vs = parseFloat(sliderVs.value);
const processTime = parseFloat(sliderProcess.value);
// Display value update
valDepth.textContent = `${depth} km`;
valDist.textContent = `${dist} km`;
valVs.textContent = `${vs.toFixed(1)} km/s`;
valProcess.textContent = `${processTime.toFixed(1)} s`;
// Geometry math
const hypoDist = Math.sqrt(dist*dist + depth*depth);
const sTravelTime = hypoDist / vs;
const leadTime = sTravelTime - processTime;
// Blind zone radius (ground distance when S-wave wavefront intersects ground surface at the processing time)
// S-wave path distance at processing time: s_path = processTime * vs
// If path is less than focal depth, S-wave hasn't even reached the ground surface directly above epicenter.
let blindRadius = 0;
const sPath = processTime * vs;
if (sPath > depth) {
blindRadius = Math.sqrt(sPath*sPath - depth*depth);
}
// Render text
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";
}
// Save to window state for canvas redraw
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");
// Set resolution based on DPI
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;
// Drawing coordinates mapping
// Earth surface is a horizontal line at y = 100
const ySurface = 80;
// Center is epicenter at x = w / 2 - 100
const xEpicenter = w / 2 - 80;
// Scale pixels: 1 km = 2 pixels
const scale = 2.0;
// Draw Earth crust layer
ctx.fillStyle = "rgba(10, 15, 26, 0.4)";
ctx.fillRect(0, ySurface, w, h - ySurface);
// Draw ground line
ctx.strokeStyle = "#475569";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, ySurface);
ctx.lineTo(w, ySurface);
ctx.stroke();
// Epicenter
ctx.fillStyle = "#94a3b8";
ctx.beginPath();
ctx.arc(xEpicenter, ySurface, 4, 0, 2*Math.PI);
ctx.fill();
// Hypocenter (Earthquake Focus)
const yFocus = ySurface + state.depth * scale;
ctx.fillStyle = "var(--accent-rose)";
ctx.beginPath();
ctx.arc(xEpicenter, yFocus, 6, 0, 2*Math.PI);
ctx.fill();
// Glow for Hypocenter
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();
// Target Station (user epicenter distance)
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();
// Label for target
ctx.fillStyle = "white";
ctx.font = "bold 11px sans-serif";
ctx.fillText("目標觀測點", xStation - 25, ySurface - 12);
// Wavefront propagation circle
// S-wave wavefront path distance
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([]);
// P-wave wavefront path (approx 1.73x faster than S-wave)
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();
// Shaded Blind Zone on surface
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);
// Draw surface indicator for blind zone
ctx.strokeStyle = "var(--accent-rose)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(xEpicenter - rBlindPx, ySurface);
ctx.lineTo(xEpicenter + rBlindPx, ySurface);
ctx.stroke();
// Blind Zone Label
ctx.fillStyle = "var(--accent-rose)";
ctx.font = "bold 11px sans-serif";
ctx.fillText(`預警盲區半徑: ${state.blindRadius.toFixed(1)} km`, xEpicenter - 65, ySurface + 24);
}
// S-wave wavefront intersection with ground
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();
}
// Draw ray from Hypocenter to target station
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xEpicenter, yFocus);
ctx.lineTo(xStation, ySurface);
ctx.stroke();
// Annotations
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);
}
// 5. AI Agent Swarm network visualizer
function initSwarmVisualizer() {
const swarmCanvas = document.getElementById("agent-swarm-canvas");
if (!swarmCanvas) return;
// Node details
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);
// Set node positions dynamically centered to canvas
const xCenter = w / 2;
const yCenter = h / 2 - 40;
// Re-adjust node coordinates relative to canvas center
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;
// Draw Links
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();
// Draw dynamic animated pulse dot on links
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();
});
// Draw Nodes
agentNodes.forEach(node => {
const isSelected = node.id === selectedNodeId;
// Glowing shadow
if (isSelected) {
ctx.shadowColor = node.color;
ctx.shadowBlur = 15;
}
// Node fill
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();
// Reset shadow
ctx.shadowBlur = 0;
// Text
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);
});
};
// Click handler
swarmCanvas.addEventListener("click", (e) => {
const rect = swarmCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if clicked node
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;
}
}
});
// Animation loop for swarm
let animId;
const loop = () => {
if (document.getElementById("tab-swarm").classList.contains("active")) {
drawSwarm();
}
animId = requestAnimationFrame(loop);
};
loop();
// Select default node
updateSwarmOverlay(agentNodes[0]);
}
// 6. Search Engine Controller
function initSearchEngine() {
// Build slide index
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";
// Highlight matching snippet
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];
}
// Highlight text snippet helper
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);
});
});
}
// 7. Key Highlights Section Controller
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`;
// Category tag display
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>
`;
// Lightbox click handler
card.querySelector(".highlight-img-click").addEventListener("click", () => {
lightroomImgEl.src = slideImgPath;
lightroomEl.style.display = "flex";
});
// Slide Jump click handler
card.querySelector(".jump-to-slide-btn").addEventListener("click", () => {
jumpToPresentation(h.pptId, h.slideNum);
});
container.appendChild(card);
});
};
// Filter Buttons binding
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);
});
});
// Initial render
renderHighlights("all");
}