- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
370 lines
11 KiB
Markdown
370 lines
11 KiB
Markdown
# 時間軸標籤避碰改進(v9.0) - 固定5泳道 + 貪婪避讓算法
|
||
|
||
## 核心轉變
|
||
|
||
### 從動態層級到固定5泳道 + 智能分配
|
||
|
||
**v8.0 的問題**:
|
||
- ❌ D3 Force 雖然避碰好,但實際效果不理想
|
||
- ❌ 標籤移動幅度大,視覺混亂
|
||
- ❌ 邊緣截斷問題難以完全解決
|
||
|
||
**v9.0 的解決方案 - 回歸 Plotly + 智能優化**:
|
||
- ✅ **固定 5 個泳道**(上方 3 個 + 下方 2 個)
|
||
- ✅ **貪婪算法選擇最佳泳道**
|
||
- ✅ **考慮連接線遮擋**
|
||
- ✅ **考慮文字框重疊**
|
||
|
||
---
|
||
|
||
## 技術實現
|
||
|
||
### 1. 固定 5 泳道配置
|
||
|
||
```python
|
||
# 固定 5 個泳道
|
||
SWIM_LANES = [
|
||
{'index': 0, 'side': 'upper', 'ratio': 0.25}, # 上方泳道 1(最低)
|
||
{'index': 1, 'side': 'upper', 'ratio': 0.55}, # 上方泳道 2(中)
|
||
{'index': 2, 'side': 'upper', 'ratio': 0.85}, # 上方泳道 3(最高)
|
||
{'index': 3, 'side': 'lower', 'ratio': 0.25}, # 下方泳道 1(最低)
|
||
{'index': 4, 'side': 'lower', 'ratio': 0.55}, # 下方泳道 2(最高)
|
||
]
|
||
```
|
||
|
||
### 2. 貪婪算法選擇泳道
|
||
|
||
```python
|
||
def greedy_lane_assignment(event, occupied_lanes):
|
||
"""
|
||
為事件選擇最佳泳道
|
||
|
||
考慮因素:
|
||
1. 文字框水平重疊
|
||
2. 連接線垂直交叉
|
||
3. 優先選擇碰撞最少的泳道
|
||
"""
|
||
best_lane = None
|
||
min_conflicts = float('inf')
|
||
|
||
for lane_id in range(5):
|
||
conflicts = calculate_conflicts(event, lane_id, occupied_lanes)
|
||
if conflicts < min_conflicts:
|
||
min_conflicts = conflicts
|
||
best_lane = lane_id
|
||
|
||
return best_lane
|
||
```
|
||
|
||
### 3. 衝突計算
|
||
|
||
```python
|
||
def calculate_conflicts(event, lane_id, occupied_lanes):
|
||
"""
|
||
計算選擇特定泳道的衝突數量
|
||
|
||
Returns:
|
||
conflict_score: 衝突分數(越低越好)
|
||
"""
|
||
score = 0
|
||
|
||
# 檢查文字框水平重疊
|
||
for occupied in occupied_lanes[lane_id]:
|
||
if text_boxes_overlap(event, occupied):
|
||
score += 10 # 重疊權重高
|
||
|
||
# 檢查連接線交叉
|
||
for other_lane_id in range(5):
|
||
if other_lane_id == lane_id:
|
||
continue
|
||
for occupied in occupied_lanes[other_lane_id]:
|
||
if connection_lines_cross(event, lane_id, occupied, other_lane_id):
|
||
score += 1 # 交叉權重低
|
||
|
||
return score
|
||
```
|
||
|
||
---
|
||
|
||
## ✅ 實施代碼(已完成 + L型避讓增強)
|
||
|
||
### 檔案:`backend/renderer_timeline.py`
|
||
|
||
#### 🆕 連接線避開文字框功能(v9.0 增強)
|
||
|
||
**核心思路**:在**貪婪算法選擇泳道時**就檢測連接線是否會穿過其他標籤,優先選擇不會穿過的泳道。
|
||
|
||
**新增方法**:`_check_line_intersects_textbox()` (第 460-513 行)
|
||
```python
|
||
def _check_line_intersects_textbox(self, line_x1, line_y1, line_x2, line_y2,
|
||
textbox_center_x, textbox_center_y,
|
||
textbox_width, textbox_height):
|
||
"""檢測線段是否與文字框相交"""
|
||
|
||
# 檢查水平線段是否穿過文字框
|
||
if abs(line_y1 - line_y2) < 0.01:
|
||
if box_bottom <= line_y <= box_top:
|
||
if not (line_x_max < box_left or line_x_min > box_right):
|
||
return True
|
||
|
||
# 檢查垂直線段是否穿過文字框
|
||
if abs(line_x1 - line_x2) < 0.01:
|
||
if box_left <= line_x <= box_right:
|
||
if not (line_y_max < box_bottom or line_y_min > box_top):
|
||
return True
|
||
|
||
return False
|
||
```
|
||
|
||
**增強的衝突分數計算**(第 345-458 行):
|
||
在貪婪算法中增加"連接線穿過其他文字框"的檢測:
|
||
|
||
```python
|
||
def _calculate_lane_conflicts(self, ...):
|
||
# 1. 文字框水平重疊(高權重:10.0)
|
||
for occupied in occupied_lanes[lane_idx]:
|
||
if 重疊:
|
||
score += 10.0 * overlap_ratio
|
||
|
||
# 2. 連接線穿過其他文字框(高權重:8.0)✨ 新增
|
||
# 檢查連接線的三段路徑是否會穿過已有標籤
|
||
for occupied in all_occupied_lanes:
|
||
# 檢查垂直線段1
|
||
if self._check_line_intersects_textbox(event_x, 0, event_x, mid_y, ...):
|
||
score += 8.0
|
||
|
||
# 檢查水平線段
|
||
if self._check_line_intersects_textbox(event_x, mid_y, label_x, mid_y, ...):
|
||
score += 8.0
|
||
|
||
# 檢查垂直線段2
|
||
if self._check_line_intersects_textbox(label_x, mid_y, label_x, label_y, ...):
|
||
score += 8.0
|
||
|
||
# 3. 連接線交叉(低權重:1.0)
|
||
if 不同側 and 時間重疊:
|
||
score += 1.0
|
||
```
|
||
|
||
**結果**:貪婪算法會自動選擇連接線不穿過其他標籤的泳道,大幅改善視覺清晰度。
|
||
|
||
#### 1. 新增 `_calculate_label_positions()` 方法(第 250-343 行)
|
||
```python
|
||
def _calculate_label_positions(self, events, start_date, end_date):
|
||
"""v9.0 - 固定5泳道 + 貪婪避讓算法"""
|
||
|
||
# 固定 5 個泳道配置
|
||
SWIM_LANES = [
|
||
{'index': 0, 'side': 'upper', 'ratio': 0.25},
|
||
{'index': 1, 'side': 'upper', 'ratio': 0.55},
|
||
{'index': 2, 'side': 'upper', 'ratio': 0.85},
|
||
{'index': 3, 'side': 'lower', 'ratio': 0.25},
|
||
{'index': 4, 'side': 'lower', 'ratio': 0.55},
|
||
]
|
||
|
||
# 追蹤每個泳道的佔用情況
|
||
occupied_lanes = {i: [] for i in range(5)}
|
||
|
||
# 貪婪算法:按時間順序處理每個事件
|
||
for event_idx, event in enumerate(events):
|
||
# 計算標籤時間範圍
|
||
label_start = event_seconds - label_width_seconds / 2 - safety_margin
|
||
label_end = event_seconds + label_width_seconds / 2 + safety_margin
|
||
|
||
# 為該事件選擇最佳泳道
|
||
best_lane = None
|
||
min_conflicts = float('inf')
|
||
|
||
for lane_config in SWIM_LANES:
|
||
conflict_score = self._calculate_lane_conflicts(...)
|
||
if conflict_score < min_conflicts:
|
||
min_conflicts = conflict_score
|
||
best_lane = lane_config
|
||
|
||
# 記錄佔用情況並返回結果
|
||
occupied_lanes[lane_idx].append({...})
|
||
result.append({'swim_lane': lane_idx, ...})
|
||
```
|
||
|
||
#### 2. 新增 `_calculate_lane_conflicts()` 方法(第 345-413 行)
|
||
```python
|
||
def _calculate_lane_conflicts(self, event_x, label_start, label_end,
|
||
lane_idx, lane_config, occupied_lanes,
|
||
total_seconds):
|
||
"""計算將事件放置在特定泳道的衝突分數"""
|
||
|
||
score = 0.0
|
||
|
||
# 1. 檢查同泳道的文字框水平重疊(高權重:10.0)
|
||
for occupied in occupied_lanes[lane_idx]:
|
||
if not (label_end < occupied['start'] or label_start > occupied['end']):
|
||
overlap_ratio = ...
|
||
score += 10.0 * overlap_ratio
|
||
|
||
# 2. 檢查與其他泳道的連接線交叉(低權重:1.0)
|
||
for other_lane_idx in range(5):
|
||
for occupied in occupied_lanes[other_lane_idx]:
|
||
if 時間範圍重疊 and 在不同側:
|
||
score += 1.0 # 交叉權重低
|
||
|
||
return score
|
||
```
|
||
|
||
#### 3. 更新 `_render_horizontal()` 方法
|
||
- **第 463-483 行**:使用新的泳道數據結構
|
||
```python
|
||
label_positions = self._calculate_label_positions(events, start_date, end_date)
|
||
|
||
for i, event in enumerate(events):
|
||
pos_info = label_positions[i]
|
||
swim_lane = pos_info['swim_lane']
|
||
swim_lane_config = pos_info['swim_lane_config']
|
||
label_y = pos_info['label_y'] # 預先計算的 Y 座標
|
||
```
|
||
|
||
- **第 499-509 行**:更新 marker 數據結構
|
||
```python
|
||
markers.append({
|
||
'event_x': event_date,
|
||
'label_x': label_x,
|
||
'label_y': label_y, # v9.0 使用預先計算的 Y 座標
|
||
'swim_lane': swim_lane,
|
||
'swim_lane_config': swim_lane_config,
|
||
...
|
||
})
|
||
```
|
||
|
||
- **第 559-591 行**:使用固定泳道 ratio 計算連接線
|
||
```python
|
||
lane_ratio = swim_lane_config['ratio']
|
||
mid_y = label_y * lane_ratio
|
||
```
|
||
|
||
- **第 630-634 行**:固定 Y 軸範圍
|
||
```python
|
||
y_range_max = 3.5 # 上方最高層 + 邊距
|
||
y_range_min = -2.5 # 下方最低層 + 邊距
|
||
```
|
||
|
||
---
|
||
|
||
## 🧪 測試驗證
|
||
|
||
### 測試步驟
|
||
|
||
1. **啟動應用程式**
|
||
```bash
|
||
python app.py
|
||
```
|
||
|
||
2. **導入測試資料**
|
||
- 使用 `demo_project_timeline.csv`(15 個事件)
|
||
- 或使用 `demo_life_events.csv`(11 個事件)
|
||
|
||
3. **生成時間軸**
|
||
- 選擇 Plotly 渲染模式
|
||
- 點擊「生成時間軸」按鈕
|
||
|
||
4. **觀察效果**
|
||
- ✅ 檢查是否有 5 個固定泳道
|
||
- ✅ 檢查文字框是否無重疊
|
||
- ✅ 檢查連接線是否交叉最少
|
||
- ✅ 檢查視覺效果是否清晰
|
||
|
||
---
|
||
|
||
## 📊 v9.0 與前版本對比
|
||
|
||
| 項目 | v8.0 (D3 Force) | v9.0 (固定5泳道 + 貪婪算法) |
|
||
|------|----------------|---------------------------|
|
||
| **泳道數量** | 動態(無限制) | 固定 5 個 |
|
||
| **標籤分配** | 力導向模擬 | 貪婪算法 |
|
||
| **避碰策略** | 物理碰撞力 | 衝突分數計算 |
|
||
| **文字框重疊** | ❌ 偶爾發生 | ✅ 高權重避免(10.0) |
|
||
| **連接線交叉** | ❌ 較多 | ✅ 低權重優化(1.0) |
|
||
| **計算複雜度** | O(n² × iterations) | O(n × 5) = O(n) |
|
||
| **視覺穩定性** | ⚠️ 不穩定(動態) | ✅ 穩定(固定) |
|
||
| **可預測性** | ❌ 低 | ✅ 高 |
|
||
|
||
---
|
||
|
||
## 🎯 v9.0 優勢
|
||
|
||
1. **固定泳道** - 視覺穩定,易於理解
|
||
2. **貪婪算法** - 快速高效,O(n) 複雜度
|
||
3. **衝突分數** - 精確控制重疊和交叉的優先級
|
||
4. **可調優** - 簡單調整權重即可改變行為
|
||
5. **回歸 Plotly** - 成熟穩定的渲染引擎
|
||
6. **🆕 連接線避讓** - 選擇泳道時避免連接線穿過標籤,視覺清晰
|
||
|
||
---
|
||
|
||
## 🔧 參數調整(可選)
|
||
|
||
如需調整避讓行為,可修改 `_calculate_lane_conflicts()` 方法中的權重:
|
||
|
||
```python
|
||
# 文字框重疊權重(默認:10.0)
|
||
score += 10.0 * overlap_ratio
|
||
|
||
# 連接線穿過文字框權重(默認:8.0)✨ 新增
|
||
score += 8.0
|
||
|
||
# 連接線交叉權重(默認:1.0)
|
||
score += 1.0
|
||
|
||
# 同側遮擋權重(默認:0.5)
|
||
score += 0.5
|
||
```
|
||
|
||
**建議**:
|
||
- 文字框重疊權重 10.0:最高優先級,必須避免
|
||
- 連接線穿過文字框 8.0:次高優先級,嚴重影響可讀性
|
||
- 連接線交叉權重 1.0:低優先級,視覺影響小
|
||
- 保持比例 10:8:1:0.5 通常效果最佳
|
||
|
||
---
|
||
|
||
## ✅ 實施總結
|
||
|
||
- **實施時間**:約 2 小時
|
||
- **修改檔案**:1 個(`backend/renderer_timeline.py`)
|
||
- **新增方法**:3 個
|
||
- `_calculate_label_positions()` - 固定5泳道 + 貪婪算法
|
||
- `_calculate_lane_conflicts()` - 衝突分數計算(含連接線穿過檢測)
|
||
- `_check_line_intersects_textbox()` - 線段與文字框碰撞檢測
|
||
- **程式碼行數**:約 280 行
|
||
- **測試狀態**:待驗證
|
||
|
||
**v9.0 已完成(含連接線避讓增強)!現在請啟動應用並測試效果。** 🎉
|
||
|
||
---
|
||
|
||
## 🎨 連接線避讓示意圖
|
||
|
||
### 問題場景
|
||
```
|
||
標籤B
|
||
↑
|
||
|
|
||
|─────────[標籤A]─────→ 標籤A
|
||
| 遮擋! ↑
|
||
| |
|
||
●─────────────────────●
|
||
事件點B 事件點A
|
||
```
|
||
**問題**:標籤B的連接線(水平線段)穿過標籤A的文字框
|
||
|
||
### v9.0 解決方案
|
||
```
|
||
標籤B 標籤A
|
||
↑ ↑
|
||
| |
|
||
|────→ ←─────| (較高泳道)
|
||
| |
|
||
| [標籤A] |
|
||
●───────────────────●
|
||
事件點B 事件點A
|
||
```
|
||
**解決**:貪婪算法讓標籤B選擇較高泳道,連接線不穿過標籤A
|