- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
495 lines
14 KiB
Markdown
495 lines
14 KiB
Markdown
# 遷移到 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)
|
||
|
||
請告訴我您的選擇!
|