v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
494
MIGRATION_TO_D3_FORCE.md
Normal file
494
MIGRATION_TO_D3_FORCE.md
Normal file
@@ -0,0 +1,494 @@
|
||||
# 遷移到 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<SVGSVGElement>(null);
|
||||
const [simulation, setSimulation] = useState<d3.Simulation<Node, undefined> | 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<Node>()
|
||||
.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<Node>(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<Node>(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<SVGLineElement, any>('.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<SVGCircleElement, Node>('.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<SVGGElement, Node>('.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 (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={height}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 步驟 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<any[]>([]);
|
||||
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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-secondary-500 p-6">
|
||||
{/* ... 現有代碼 ... */}
|
||||
|
||||
{/* 渲染模式切換 */}
|
||||
<div className="card">
|
||||
<div className="flex gap-4 mb-4">
|
||||
<button
|
||||
onClick={() => setRenderMode('d3')}
|
||||
className={renderMode === 'd3' ? 'btn-primary' : 'btn-secondary'}
|
||||
>
|
||||
D3.js 力導向
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRenderMode('plotly')}
|
||||
className={renderMode === 'plotly' ? 'btn-primary' : 'btn-secondary'}
|
||||
>
|
||||
Plotly(舊版)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderMode === 'd3' && events.length > 0 && (
|
||||
<D3Timeline events={events} />
|
||||
)}
|
||||
|
||||
{renderMode === 'plotly' && plotlyData && (
|
||||
<Plot data={plotlyData.data} layout={plotlyLayout} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 關鍵技術點
|
||||
|
||||
### 1. 固定事件點位置
|
||||
```typescript
|
||||
{
|
||||
fx: eventX, // 固定 X - 保證時間準確性
|
||||
fy: axisY, // 固定 Y - 在時間軸上
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 碰撞力(避免重疊)
|
||||
```typescript
|
||||
.force('collide', d3.forceCollide<Node>()
|
||||
.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)
|
||||
|
||||
請告訴我您的選擇!
|
||||
Reference in New Issue
Block a user