在學習地圖的互動體驗中,拖曳和縮放功能是使用者最常使用的操作。今天我們要為 GASO 添加這些核心互動功能,讓使用者能夠像操作 Google Maps 一樣流暢地瀏覽學習地圖。
首先,我們需要在狀態物件中加入拖曳相關的變數:
// 拖曳狀態管理
state.isDragging = false;
state.dragStartX = 0;
state.dragStartY = 0;
state.dragOffsetX = 0;
state.dragOffsetY = 0;
state.currentTranslateX = 0;
state.currentTranslateY = 0;
// 開始拖曳
function startDrag(e) {
  // 防止在節點上拖曳
  if (e.target.closest('g.node')) return;
  
  state.isDragging = true;
  graph.classList.add('dragging');
  zoomInner.classList.add('dragging');
  
  // 記錄起始位置
  state.dragStartX = e.clientX;
  state.dragStartY = e.clientY;
  state.dragOffsetX = state.currentTranslateX;
  state.dragOffsetY = state.currentTranslateY;
  
  console.log('開始拖曳,起始位置:', state.dragStartX, state.dragStartY);
}
// 拖曳中
function drag(e) {
  if (!state.isDragging) return;
  
  e.preventDefault();
  e.stopPropagation();
  
  // 計算位移
  const deltaX = e.clientX - state.dragStartX;
  const deltaY = e.clientY - state.dragStartY;
  
  // 更新位置
  state.currentTranslateX = state.dragOffsetX + deltaX;
  state.currentTranslateY = state.dragOffsetY + deltaY;
  
  // 應用變換
  applyDragTransform();
}
// 拖曳結束
function endDrag(e) {
  if (!state.isDragging) return;
  
  state.isDragging = false;
  graph.classList.remove('dragging');
  zoomInner.classList.remove('dragging');
  
  console.log('拖曳結束');
}
// 應用拖曳變換
function applyDragTransform() {
  const scale = state.scalePct / 100;
  zoomInner.style.transform = `translate(${state.currentTranslateX}px, ${state.currentTranslateY}px) scale(${scale})`;
}
// 綁定拖曳事件
graph.addEventListener('mousedown', startDrag);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', endDrag);
// 觸控支援
graph.addEventListener('touchstart', (e) => {
  if (e.touches.length === 1) {
    const touch = e.touches[0];
    const mouseEvent = new MouseEvent('mousedown', {
      clientX: touch.clientX,
      clientY: touch.clientY
    });
    startDrag(mouseEvent);
  }
});
document.addEventListener('touchmove', (e) => {
  if (e.touches.length === 1 && state.isDragging) {
    e.preventDefault();
    const touch = e.touches[0];
    const mouseEvent = new MouseEvent('mousemove', {
      clientX: touch.clientX,
      clientY: touch.clientY
    });
    drag(mouseEvent);
  }
});
document.addEventListener('touchend', (e) => {
  if (state.isDragging) {
    endDrag(e);
  }
});
// Google Maps 風格的滾輪縮放
graph.addEventListener("wheel", (e) => {
  e.preventDefault();
  
  // 取得滑鼠相對於畫布的位置
  const rect = graph.getBoundingClientRect();
  const mouseX = e.clientX - rect.left;
  const mouseY = e.clientY - rect.top;
  
  // 計算縮放前的滑鼠相對於地圖的位置
  const scale = state.scalePct / 100;
  const mapX = (mouseX - state.currentTranslateX) / scale;
  const mapY = (mouseY - state.currentTranslateY) / scale;
  
  // 計算縮放變化
  const delta = Math.sign(e.deltaY);
  const zoomChange = delta > 0 ? -10 : 10;
  const newScale = Math.max(10, Math.min(500, state.scalePct + zoomChange));
  const scaleRatio = newScale / state.scalePct;
  
  // 更新縮放比例
  state.scalePct = newScale;
  
  // 計算新的位移,保持滑鼠位置不變
  const newScaleValue = state.scalePct / 100;
  state.currentTranslateX = mouseX - mapX * newScaleValue;
  state.currentTranslateY = mouseY - mapY * newScaleValue;
  
  // 添加縮放動畫效果
  zoomInner.classList.add('zooming');
  
  // 應用變換
  applyDragTransform();
  zoomLabel.textContent = `${state.scalePct}%`;
  zoomRange.value = String(state.scalePct);
  
  // 移除動畫類別
  setTimeout(() => {
    zoomInner.classList.remove('zooming');
  }, 300);
  
  console.log(`縮放到 ${state.scalePct}%,滑鼠位置: (${mouseX}, ${mouseY})`);
}, { passive: false });
#graph { 
  cursor: grab;
  user-select: none;
}
#graph.dragging {
  cursor: grabbing;
}
#graph.drag-mode {
  cursor: grab;
}
#graph.drag-mode.dragging {
  cursor: grabbing;
}
#zoomInner { 
  transition: transform 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
#zoomInner.dragging {
  transition: none;
}
#zoomInner.zooming {
  transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
#graph {
  overflow: hidden; /* 改為 hidden,讓拖曳更流暢 */
  user-select: none;
}
事件處理順序
mousedown → 開始拖曳mousemove → 拖曳過程mouseup → 結束拖曳位置計算
const deltaX = e.clientX - state.dragStartX;
const deltaY = e.clientY - state.dragStartY;
變換應用
zoomInner.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
以滑鼠為中心縮放
const mapX = (mouseX - state.currentTranslateX) / scale;
const mapY = (mouseY - state.currentTranslateY) / scale;
保持滑鼠位置不變
state.currentTranslateX = mouseX - mapX * newScaleValue;
state.currentTranslateY = mouseY - mapY * newScaleValue;
縮放範圍限制
const newScale = Math.max(10, Math.min(500, state.scalePct + zoomChange));
// 將觸控事件轉換為滑鼠事件
const mouseEvent = new MouseEvent('mousedown', {
  clientX: touch.clientX,
  clientY: touch.clientY
});
if (e.touches.length === 1) {
  // 單點觸控,視為拖曳
} else if (e.touches.length === 2) {
  // 雙點觸控,視為縮放
}
// 使用 requestAnimationFrame 優化拖曳效能
let animationId;
function drag(e) {
  if (!state.isDragging) return;
  
  if (animationId) {
    cancelAnimationFrame(animationId);
  }
  
  animationId = requestAnimationFrame(() => {
    // 拖曳邏輯
    applyDragTransform();
  });
}
// 只在需要時綁定事件
if (state.isDragMode) {
  graph.addEventListener('mousedown', startDrag);
}
// 清理事件監聽器
function cleanup() {
  document.removeEventListener('mousemove', drag);
  document.removeEventListener('mouseup', endDrag);
}
游標變化
grab
grabbing
動畫效果
邊界處理
節點保護
if (e.target.closest('g.node')) return;
事件優先級
console.log('開始拖曳,起始位置:', state.dragStartX, state.dragStartY);
console.log('拖曳中,偏移:', deltaX, deltaY);
console.log(`縮放到 ${state.scalePct}%,滑鼠位置: (${mouseX}, ${mouseY})`);
拖曳不流暢
overflow: hidden
user-select: none
縮放位置偏移
觸控不響應
✅ 流暢拖曳:像 Google Maps 一樣的拖曳體驗
✅ 智能縮放:以滑鼠位置為中心的縮放
✅ 觸控支援:完整的行動裝置支援
✅ 動畫效果:平滑的視覺回饋
✅ 效能優化:流暢的 60fps 體驗
今天的實作為 GASO 添加了核心的互動功能:
這些互動功能讓 GASO 的學習地圖從靜態展示升級為動態互動平台,使用者可以自由探索學習路徑,就像在數位地圖上導航一樣自然流暢!