在學習地圖的互動體驗中,拖曳和縮放功能是使用者最常使用的操作。今天我們要為 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 的學習地圖從靜態展示升級為動態互動平台,使用者可以自由探索學習路徑,就像在數位地圖上導航一樣自然流暢!