Files
Timeline_Generator/MIGRATION_TO_D3_FORCE.md
beabigegg 2d37d23bcf v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 11:35:29 +08:00

495 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 遷移到 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
請告訴我您的選擇!