v9.5: 實作標籤完全不重疊算法

- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-11-06 11:35:29 +08:00
commit 2d37d23bcf
83 changed files with 22971 additions and 0 deletions

494
MIGRATION_TO_D3_FORCE.md Normal file
View 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
請告訴我您的選擇!