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

369
IMPROVEMENTS_v9.md Normal file
View 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