v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
369
IMPROVEMENTS_v9.md
Normal file
369
IMPROVEMENTS_v9.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# 時間軸標籤避碰改進(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
|
||||
Reference in New Issue
Block a user