# 時間軸標籤避碰改進(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