Files
Timeline_Generator/IMPROVEMENTS_v9.md
beabigegg 2d37d23bcf v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 11:35:29 +08:00

11 KiB
Raw Blame History

時間軸標籤避碰改進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  # 下方最低層 + 邊距
    

🧪 測試驗證

測試步驟

  1. 啟動應用程式

    python app.py
    
  2. 導入測試資料

    • 使用 demo_project_timeline.csv15 個事件)
    • 或使用 demo_life_events.csv11 個事件)
  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() 方法中的權重:

# 文字框重疊權重默認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