- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
14 KiB
14 KiB
遷移到 D3.js Force-Directed Layout - 實施計劃
📋 目標
將時間軸標籤避讓邏輯從後端 Plotly遷移到前端 D3.js d3-force,實現專業的標籤碰撞避讓。
🏗️ 架構變更
當前架構(v7.0)
┌─────────┐ 事件資料 ┌─────────┐ Plotly圖表 ┌─────────┐
│ Python │ --------> │ 計算 │ ----------> │ React │
│ 後端 │ │ 標籤位置 │ │ 前端 │
└─────────┘ └─────────┘ └─────────┘
❌ 標籤避讓在這裡(效果差)
新架構(D3 Force)
┌─────────┐ 事件資料 ┌─────────────┐ 渲染座標 ┌─────────┐
│ Python │ --------> │ D3 Force │ ---------> │ React │
│ 後端 │ (乾淨) │ 標籤避讓 │ │ 前端 │
└─────────┘ └─────────────┘ └─────────┘
✅ 力導向演算法在這裡
📦 步驟 1: 安裝 D3.js 依賴
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
@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 端點作為備選
@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
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
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. 固定事件點位置
{
fx: eventX, // 固定 X - 保證時間準確性
fy: axisY, // 固定 Y - 在時間軸上
}
2. 碰撞力(避免重疊)
.force('collide', d3.forceCollide<Node>()
.radius(d => Math.max(d.labelWidth / 2, d.labelHeight / 2) + 10)
.strength(0.8)
)
3. 連結力(彈簧效果)
.force('link', d3.forceLink(links)
.distance(100) // 理想距離
.strength(0.3) // 彈簧強度
)
4. 限制標籤 X 偏移
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)
請告訴我您的選擇!