- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
11 KiB
11 KiB
時間軸標籤避碰改進(v9.0) - 固定5泳道 + 貪婪避讓算法
核心轉變
從動態層級到固定5泳道 + 智能分配
v8.0 的問題:
- ❌ D3 Force 雖然避碰好,但實際效果不理想
- ❌ 標籤移動幅度大,視覺混亂
- ❌ 邊緣截斷問題難以完全解決
v9.0 的解決方案 - 回歸 Plotly + 智能優化:
- ✅ 固定 5 個泳道(上方 3 個 + 下方 2 個)
- ✅ 貪婪算法選擇最佳泳道
- ✅ 考慮連接線遮擋
- ✅ 考慮文字框重疊
技術實現
1. 固定 5 泳道配置
# 固定 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. 貪婪算法選擇泳道
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. 衝突計算
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 行)
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 行): 在貪婪算法中增加"連接線穿過其他文字框"的檢測:
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 行)
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 行)
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 行:使用新的泳道數據結構
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 數據結構
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 計算連接線
lane_ratio = swim_lane_config['ratio'] mid_y = label_y * lane_ratio -
第 630-634 行:固定 Y 軸範圍
y_range_max = 3.5 # 上方最高層 + 邊距 y_range_min = -2.5 # 下方最低層 + 邊距
🧪 測試驗證
測試步驟
-
啟動應用程式
python app.py -
導入測試資料
- 使用
demo_project_timeline.csv(15 個事件) - 或使用
demo_life_events.csv(11 個事件)
- 使用
-
生成時間軸
- 選擇 Plotly 渲染模式
- 點擊「生成時間軸」按鈕
-
觀察效果
- ✅ 檢查是否有 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 優勢
- 固定泳道 - 視覺穩定,易於理解
- 貪婪算法 - 快速高效,O(n) 複雜度
- 衝突分數 - 精確控制重疊和交叉的優先級
- 可調優 - 簡單調整權重即可改變行為
- 回歸 Plotly - 成熟穩定的渲染引擎
- 🆕 連接線避讓 - 選擇泳道時避免連接線穿過標籤,視覺清晰
🔧 參數調整(可選)
如需調整避讓行為,可修改 _calculate_lane_conflicts() 方法中的權重:
# 文字框重疊權重(默認: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