# 遷移到 D3.js Force-Directed Layout - 實施計劃 ## 📋 目標 將時間軸標籤避讓邏輯從**後端 Plotly**遷移到**前端 D3.js d3-force**,實現專業的標籤碰撞避讓。 --- ## 🏗️ 架構變更 ### 當前架構(v7.0) ``` ┌─────────┐ 事件資料 ┌─────────┐ Plotly圖表 ┌─────────┐ │ Python │ --------> │ 計算 │ ----------> │ React │ │ 後端 │ │ 標籤位置 │ │ 前端 │ └─────────┘ └─────────┘ └─────────┘ ❌ 標籤避讓在這裡(效果差) ``` ### 新架構(D3 Force) ``` ┌─────────┐ 事件資料 ┌─────────────┐ 渲染座標 ┌─────────┐ │ Python │ --------> │ D3 Force │ ---------> │ React │ │ 後端 │ (乾淨) │ 標籤避讓 │ │ 前端 │ └─────────┘ └─────────────┘ └─────────┘ ✅ 力導向演算法在這裡 ``` --- ## 📦 步驟 1: 安裝 D3.js 依賴 ```bash cd frontend-react npm install d3 d3-force d3-scale d3-axis d3-selection npm install --save-dev @types/d3 ``` **安裝的模組**: - `d3-force`: 力導向布局核心 - `d3-scale`: 時間軸刻度 - `d3-axis`: 軸線繪製 - `d3-selection`: DOM 操作 --- ## 🔧 步驟 2: 修改後端 API ### 2.1 新增端點:返回原始事件資料 **檔案**: `backend/main.py` ```python @router.get("/api/events/raw") async def get_raw_events(): """ 返回原始事件資料(不做任何布局計算) 供前端 D3.js 使用 """ events = event_manager.get_events() return { "success": True, "events": [ { "id": i, "start": event.start.isoformat(), "end": event.end.isoformat() if event.end else None, "title": event.title, "description": event.description, "color": event.color or "#3B82F6", "layer": event.layer } for i, event in enumerate(events) ], "count": len(events) } ``` ### 2.2 保留 Plotly 端點作為備選 ```python @router.post("/api/render") # 保留舊版 @router.post("/api/render/plotly") # 明確標記 async def render_plotly_timeline(config: TimelineConfig): # ... 現有代碼 ... ``` --- ## 🎨 步驟 3: 創建 D3 時間軸組件 ### 3.1 創建組件文件 **檔案**: `frontend-react/src/components/D3Timeline.tsx` ```typescript import { useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; interface Event { id: number; start: string; end?: string; title: string; description: string; color: string; layer: number; } interface D3TimelineProps { events: Event[]; width?: number; height?: number; } interface Node extends d3.SimulationNodeDatum { id: number; type: 'event' | 'label'; eventId: number; x: number; y: number; fx?: number | null; // 固定 X(事件點) fy?: number | null; // 固定 Y(事件點在時間軸上) event: Event; labelWidth: number; labelHeight: number; } export default function D3Timeline({ events, width = 1200, height = 600 }: D3TimelineProps) { const svgRef = useRef(null); const [simulation, setSimulation] = useState | null>(null); useEffect(() => { if (!svgRef.current || events.length === 0) return; // 清空 SVG const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); // 邊距設定 const margin = { top: 100, right: 50, bottom: 50, left: 50 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; // 創建主 group const g = svg .append('g') .attr('transform', `translate(${margin.left},${margin.top})`); // 時間範圍 const dates = events.map(e => new Date(e.start)); const xScale = d3.scaleTime() .domain([d3.min(dates)!, d3.max(dates)!]) .range([0, innerWidth]); // 時間軸線 const axisY = innerHeight / 2; g.append('line') .attr('x1', 0) .attr('x2', innerWidth) .attr('y1', axisY) .attr('y2', axisY) .attr('stroke', '#3B82F6') .attr('stroke-width', 3); // 準備節點資料 const nodes: Node[] = []; events.forEach((event, i) => { const eventX = xScale(new Date(event.start)); // 事件點節點(固定位置) nodes.push({ id: i * 2, type: 'event', eventId: i, x: eventX, y: axisY, fx: eventX, // 固定 X - 保證時間準確性 fy: axisY, // 固定 Y - 在時間軸上 event, labelWidth: 0, labelHeight: 0 }); // 標籤節點(可移動) const labelWidth = Math.max(event.title.length * 8, 120); const labelHeight = 60; const initialY = event.layer % 2 === 0 ? axisY - 150 : axisY + 150; nodes.push({ id: i * 2 + 1, type: 'label', eventId: i, x: eventX, // 初始 X 接近事件點 y: initialY, // 初始 Y 根據層級 fx: null, fy: null, event, labelWidth, labelHeight }); }); // 連接線(標籤 → 事件點) const links = nodes .filter(n => n.type === 'label') .map(label => ({ source: label.id, target: label.id - 1 // 對應的事件點 })); // D3 力導向模擬 const sim = d3.forceSimulation(nodes) // 1. 碰撞力:標籤之間互相推開 .force('collide', d3.forceCollide() .radius(d => { if (d.type === 'label') { // 使用橢圓碰撞半徑(考慮文字框寬高) return Math.max(d.labelWidth / 2, d.labelHeight / 2) + 10; } return 5; // 事件點不參與碰撞 }) .strength(0.8) ) // 2. 連結力:標籤拉向事件點(像彈簧) .force('link', d3.forceLink(links) .id(d => (d as Node).id) .distance(100) // 理想距離 .strength(0.3) // 彈簧強度 ) // 3. X 方向定位力:標籤靠近事件點的 X 座標 .force('x', d3.forceX(d => { if (d.type === 'label') { const eventNode = nodes.find(n => n.type === 'event' && n.eventId === d.eventId); return eventNode ? eventNode.x : d.x; } return d.x; }).strength(0.5)) // 4. Y 方向定位力:標籤保持在上方或下方 .force('y', d3.forceY(d => { if (d.type === 'label') { return d.y < axisY ? axisY - 120 : axisY + 120; } return axisY; }).strength(0.3)) // 5. 邊界限制 .on('tick', () => { nodes.forEach(d => { if (d.type === 'label') { // 限制 Y 範圍 if (d.y! < 20) d.y = 20; if (d.y! > innerHeight - 20) d.y = innerHeight - 20; // 限制 X 範圍(允許小幅偏移) const eventNode = nodes.find(n => n.type === 'event' && n.eventId === d.eventId)!; const maxOffset = 80; if (Math.abs(d.x! - eventNode.x!) > maxOffset) { d.x = eventNode.x! + (d.x! > eventNode.x! ? maxOffset : -maxOffset); } } }); updateVisualization(); }); setSimulation(sim); // 繪製可視化元素 function updateVisualization() { // 連接線 const linkElements = g.selectAll('.link') .data(links) .join('line') .attr('class', 'link') .attr('x1', d => { const source = nodes.find(n => n.id === (typeof d.source === 'number' ? d.source : d.source.id))!; return source.x!; }) .attr('y1', d => { const source = nodes.find(n => n.id === (typeof d.source === 'number' ? d.source : d.source.id))!; return source.y!; }) .attr('x2', d => { const target = nodes.find(n => n.id === (typeof d.target === 'number' ? d.target : d.target.id))!; return target.x!; }) .attr('y2', d => { const target = nodes.find(n => n.id === (typeof d.target === 'number' ? d.target : d.target.id))!; return target.y!; }) .attr('stroke', '#94a3b8') .attr('stroke-width', 1.5) .attr('opacity', 0.7); // 事件點 const eventNodes = g.selectAll('.event-node') .data(nodes.filter(n => n.type === 'event')) .join('circle') .attr('class', 'event-node') .attr('cx', d => d.x!) .attr('cy', d => d.y!) .attr('r', 8) .attr('fill', d => d.event.color) .attr('stroke', '#fff') .attr('stroke-width', 2); // 標籤文字框 const labelGroups = g.selectAll('.label-group') .data(nodes.filter(n => n.type === 'label')) .join('g') .attr('class', 'label-group') .attr('transform', d => `translate(${d.x! - d.labelWidth / 2},${d.y! - d.labelHeight / 2})`); // 文字框背景 labelGroups.selectAll('rect') .data(d => [d]) .join('rect') .attr('width', d => d.labelWidth) .attr('height', d => d.labelHeight) .attr('rx', 6) .attr('fill', 'white') .attr('opacity', 0.9) .attr('stroke', d => d.event.color) .attr('stroke-width', 2); // 文字內容 labelGroups.selectAll('text') .data(d => [d]) .join('text') .attr('x', d => d.labelWidth / 2) .attr('y', 20) .attr('text-anchor', 'middle') .attr('font-size', 12) .attr('font-weight', 'bold') .text(d => d.event.title); } // 初始繪製 updateVisualization(); // 清理函數 return () => { sim.stop(); }; }, [events, width, height]); return (
); } ``` --- ## 🔗 步驟 4: 整合到 App.tsx **檔案**: `frontend-react/src/App.tsx` ```typescript import { useState, useCallback } from 'react'; import D3Timeline from './components/D3Timeline'; import { timelineAPI } from './api/timeline'; function App() { const [events, setEvents] = useState([]); const [renderMode, setRenderMode] = useState<'d3' | 'plotly'>('d3'); // ... 其他現有代碼 ... // 載入原始事件資料(for D3) const loadEventsForD3 = async () => { try { const response = await axios.get('http://localhost:8000/api/events/raw'); if (response.data.success) { setEvents(response.data.events); } } catch (error) { console.error('Failed to load events:', error); } }; return (
{/* ... 現有代碼 ... */} {/* 渲染模式切換 */}
{renderMode === 'd3' && events.length > 0 && ( )} {renderMode === 'plotly' && plotlyData && ( )}
); } ``` --- ## 🎯 關鍵技術點 ### 1. 固定事件點位置 ```typescript { fx: eventX, // 固定 X - 保證時間準確性 fy: axisY, // 固定 Y - 在時間軸上 } ``` ### 2. 碰撞力(避免重疊) ```typescript .force('collide', d3.forceCollide() .radius(d => Math.max(d.labelWidth / 2, d.labelHeight / 2) + 10) .strength(0.8) ) ``` ### 3. 連結力(彈簧效果) ```typescript .force('link', d3.forceLink(links) .distance(100) // 理想距離 .strength(0.3) // 彈簧強度 ) ``` ### 4. 限制標籤 X 偏移 ```typescript const maxOffset = 80; if (Math.abs(labelX - eventX) > maxOffset) { labelX = eventX + (labelX > eventX ? maxOffset : -maxOffset); } ``` --- ## 📊 優勢對比 | 項目 | Plotly 後端 | D3 Force 前端 | |------|------------|---------------| | 標籤避讓效果 | ⚠️ 差 | ✅ 專業 | | 動態調整 | ❌ 需重新渲染 | ✅ 即時模擬 | | 性能 | ⚠️ 後端計算 | ✅ 瀏覽器端 | | 可定制性 | ❌ 有限 | ✅ 完全控制 | | 開發成本 | ✅ 低 | ⚠️ 中等 | --- ## ⏱️ 實施時程 - **步驟 1**: 安裝依賴 - **15分鐘** - **步驟 2**: 修改後端 API - **30分鐘** - **步驟 3**: 創建 D3 組件 - **2-3小時** - **步驟 4**: 整合到 App - **1小時** - **測試調優**: **1-2小時** **總計**: **半天到一天** --- ## 🚀 下一步 您希望我: **A.** 立即開始實施(按步驟執行) **B.** 先創建一個簡化版 POC 測試效果 **C.** 評估其他替代方案(vis-timeline, react-calendar-timeline) 請告訴我您的選擇!