""" 經典時間軸渲染器 創建傳統的時間軸風格: - 一條水平/垂直主軸線 - 事件點標記 - 交錯的文字標註 - 連接線 """ from datetime import datetime, timedelta from typing import List, Dict, Any, Tuple import logging from .schemas import Event, TimelineConfig, RenderResult, ThemeStyle from .path_planner import GridMap, auto_calculate_grid_resolution, find_path_bfs, simplify_path logger = logging.getLogger(__name__) def apply_force_directed_layout( label_positions: List[Dict], config: 'TimelineConfig', time_range_seconds: float, max_iterations: int = 100, repulsion_strength: float = 50.0, damping: float = 0.8 ) -> List[Dict]: """ 使用力導向演算法優化標籤位置 重要原則: 1. 事件點(event_x)位置固定,保證時間準確性 2. 標籤 X 可在事件點附近小幅移動(±5%),避免水平重疊 3. 標籤 Y 自由移動,垂直避開重疊 4. 考慮文字框實際尺寸(寬度15%,高度1.0) Args: label_positions: 標籤位置列表 config: 時間軸配置 time_range_seconds: 總時間範圍(秒) max_iterations: 最大迭代次數 repulsion_strength: 排斥力強度 damping: 阻尼係數 Returns: 優化後的標籤位置列表 """ import math if len(label_positions) == 0: return label_positions # 文字框尺寸(相對於時間範圍) label_width_ratio = 0.15 label_width = time_range_seconds * label_width_ratio label_height = 1.5 # 垂直高度(相對單位),增加到1.5以匹配實際文字框大小 # 標籤 X 軸允許的最大偏移(相對於事件點) max_x_offset = time_range_seconds * 0.05 # ±5% # 初始化速度 velocities = [{'x': 0.0, 'y': 0.0} for _ in label_positions] # 轉換為可變的位置數據 positions = [] for pos in label_positions: event_time = pos['event_x'] label_time = pos['label_x'] if isinstance(event_time, datetime) and isinstance(label_time, datetime): event_x_sec = (event_time - label_positions[0]['event_x']).total_seconds() label_x_sec = (label_time - label_positions[0]['event_x']).total_seconds() else: event_x_sec = event_time label_x_sec = label_time positions.append({ 'event_x': event_x_sec, # 固定不變 'label_x': label_x_sec, 'label_y': float(pos['label_y']), 'layer': pos['layer'] }) # 力導向迭代 for iteration in range(max_iterations): forces = [{'x': 0.0, 'y': 0.0} for _ in positions] # 計算排斥力(考慮文字框實際尺寸的矩形碰撞) for i in range(len(positions)): for j in range(i + 1, len(positions)): # 文字框 i 的邊界 i_left = positions[i]['label_x'] - label_width / 2 i_right = positions[i]['label_x'] + label_width / 2 i_top = positions[i]['label_y'] + label_height / 2 i_bottom = positions[i]['label_y'] - label_height / 2 # 文字框 j 的邊界 j_left = positions[j]['label_x'] - label_width / 2 j_right = positions[j]['label_x'] + label_width / 2 j_top = positions[j]['label_y'] + label_height / 2 j_bottom = positions[j]['label_y'] - label_height / 2 # 計算中心距離 dx = positions[j]['label_x'] - positions[i]['label_x'] dy = positions[j]['label_y'] - positions[i]['label_y'] # 檢查矩形重疊 overlap_x = (i_right > j_left and i_left < j_right) overlap_y = (i_top > j_bottom and i_bottom < j_top) if overlap_x and overlap_y: # 有重疊,計算重疊程度 overlap_width = min(i_right, j_right) - max(i_left, j_left) overlap_height = min(i_top, j_top) - max(i_bottom, j_bottom) # 計算中心距離(用於歸一化方向向量) distance = math.sqrt(dx * dx + dy * dy) if distance < 0.01: distance = 0.01 # 如果兩個文字框幾乎完全重疊,給一個隨機的小偏移 dx = 0.01 if i < j else -0.01 dy = 0.01 # 排斥力與重疊面積成正比 overlap_area = overlap_width * overlap_height repulsion = repulsion_strength * overlap_area * 10.0 # 增強排斥力 # 正確計算力的分量(歸一化方向向量 * 力大小) fx = (dx / distance) * repulsion * 0.3 # X方向減弱(保持靠近事件點) fy = (dy / distance) * repulsion # Y方向全力 forces[i]['x'] -= fx forces[i]['y'] -= fy forces[j]['x'] += fx forces[j]['y'] += fy else: # 無重疊,輕微排斥力(保持間距) distance = math.sqrt(dx * dx + dy * dy) if distance < 0.01: distance = 0.01 repulsion = repulsion_strength * 0.1 / (distance * distance) fx = (dx / distance) * repulsion * 0.3 # X方向大幅減弱 fy = (dy / distance) * repulsion forces[i]['x'] -= fx forces[i]['y'] -= fy forces[j]['x'] += fx forces[j]['y'] += fy # 計算回拉力(標籤 X 拉向事件點 X) for i in range(len(positions)): x_offset = positions[i]['label_x'] - positions[i]['event_x'] # 強回拉力,確保標籤不會偏離事件點太遠 pull_strength = 0.2 forces[i]['x'] -= x_offset * pull_strength # 更新速度和位置 max_displacement = 0.0 for i in range(len(positions)): # 更新速度 velocities[i]['x'] = (velocities[i]['x'] + forces[i]['x']) * damping velocities[i]['y'] = (velocities[i]['y'] + forces[i]['y']) * damping # 更新位置 old_x = positions[i]['label_x'] old_y = positions[i]['label_y'] positions[i]['label_x'] += velocities[i]['x'] positions[i]['label_y'] += velocities[i]['y'] # 限制 X 方向偏移(不能離事件點太遠) x_offset = positions[i]['label_x'] - positions[i]['event_x'] if abs(x_offset) > max_x_offset: positions[i]['label_x'] = positions[i]['event_x'] + (max_x_offset if x_offset > 0 else -max_x_offset) # 限制 Y 方向範圍(保持上下分離) if positions[i]['label_y'] > 0: positions[i]['label_y'] = max(0.5, min(positions[i]['label_y'], 8.0)) else: positions[i]['label_y'] = min(-0.5, max(positions[i]['label_y'], -8.0)) # 計算最大位移 displacement = math.sqrt( (positions[i]['label_x'] - old_x) ** 2 + (positions[i]['label_y'] - old_y) ** 2 ) max_displacement = max(max_displacement, displacement) # 檢查收斂 if max_displacement < 0.01: logger.info(f"力導向演算法在第 {iteration + 1} 次迭代後收斂") break # 將優化後的位置轉回 datetime reference_time = label_positions[0]['event_x'] for i, pos in enumerate(label_positions): if isinstance(reference_time, datetime): pos['label_x'] = reference_time + timedelta(seconds=positions[i]['label_x']) else: pos['label_x'] = positions[i]['label_x'] pos['label_y'] = positions[i]['label_y'] return label_positions class ClassicTimelineRenderer: """經典時間軸渲染器""" # 主題配置 THEMES = { ThemeStyle.MODERN: { 'background_color': '#FFFFFF', 'line_color': '#667EEA', 'text_color': '#1F2937', 'grid_color': '#E5E7EB', 'event_colors': ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] }, ThemeStyle.CLASSIC: { 'background_color': '#F9FAFB', 'line_color': '#6B7280', 'text_color': '#374151', 'grid_color': '#D1D5DB', 'event_colors': ['#2563EB', '#059669', '#D97706', '#DC2626', '#7C3AED', '#DB2777'] }, ThemeStyle.MINIMAL: { 'background_color': '#FFFFFF', 'line_color': '#000000', 'text_color': '#000000', 'grid_color': '#CCCCCC', 'event_colors': ['#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#555555'] }, ThemeStyle.CORPORATE: { 'background_color': '#F3F4F6', 'line_color': '#1F2937', 'text_color': '#111827', 'grid_color': '#9CA3AF', 'event_colors': ['#1F2937', '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'] }, } def __init__(self): pass def _calculate_label_positions(self, events: List[Event], start_date: datetime, end_date: datetime) -> List[Dict[str, Any]]: """ v9.0 - 固定5泳道 + 貪婪避讓算法 智能計算標籤位置以避免重疊(考慮文字框重疊和連接線交叉) Args: events: 排序後的事件列表 start_date: 時間軸起始時間 end_date: 時間軸結束時間 Returns: 每個事件的位置資訊列表: [{'swim_lane': int, 'x_offset': float}, ...] """ if not events: return [] # 固定 7 個泳道配置(上方4個 + 下方3個) # v9.1: 調整下層泳道最低位置,避免遮擋 X 軸日期文字 SWIM_LANES = [ {'index': 0, 'side': 'upper', 'ratio': 0.20}, # 上方泳道 1(最低) {'index': 1, 'side': 'upper', 'ratio': 0.40}, # 上方泳道 2 {'index': 2, 'side': 'upper', 'ratio': 0.60}, # 上方泳道 3 {'index': 3, 'side': 'upper', 'ratio': 0.80}, # 上方泳道 4(最高) {'index': 4, 'side': 'lower', 'ratio': 0.20}, # 下方泳道 1(最高,最接近時間軸) {'index': 5, 'side': 'lower', 'ratio': 0.40}, # 下方泳道 2 {'index': 6, 'side': 'lower', 'ratio': 0.50}, # 下方泳道 3(最低,降低ratio避免遮擋日期) ] # 計算總時間範圍(秒數) total_seconds = (end_date - start_date).total_seconds() # 標籤寬度(相對於時間範圍的比例) label_width_ratio = 0.15 # 15% label_width_seconds = total_seconds * label_width_ratio # 安全邊距(防止文字框邊緣相接) safety_margin = total_seconds * 0.01 # 1% 的安全邊距 # 追蹤每個泳道的佔用情況 # occupied_lanes[lane_index] = [(start_time, end_time, event_index, label_y), ...] occupied_lanes = {i: [] for i in range(7)} result = [] # v9.4: 改進的智能上下交錯算法 # 1. 強制嚴格交錯:每個事件必須與上一個事件在不同側(除非該側無可用泳道) # 2. 近日期額外加強:時間很接近時(7天內)更強制交錯 # 上下層泳道分組 UPPER_LANES = [SWIM_LANES[0], SWIM_LANES[1], SWIM_LANES[2], SWIM_LANES[3]] # index 0-3 LOWER_LANES = [SWIM_LANES[4], SWIM_LANES[5], SWIM_LANES[6]] # index 4-6 # 追蹤上下側的使用情況 last_event_seconds = None # 上一個事件的時間 last_side = None # 上一個事件使用的側別 ('upper' or 'lower') # 統計上下側使用次數,用於最終平衡 upper_count = 0 lower_count = 0 # 近日期閾值:7天以內視為近日期(加強交錯) CLOSE_DATE_THRESHOLD = 7 * 24 * 3600 # 7天的秒數 # 貪婪算法:按時間順序處理每個事件 for event_idx, event in enumerate(events): event_seconds = (event.start - start_date).total_seconds() # 計算標籤時間範圍 label_start = event_seconds - label_width_seconds / 2 - safety_margin label_end = event_seconds + label_width_seconds / 2 + safety_margin # 診斷日誌:顯示標籤時間範圍 logger.info(f"📋 事件 {event_idx} '{event.title}': " f"event_seconds={event_seconds:.0f}, " f"label範圍=[{label_start:.0f}, {label_end:.0f}] " f"(寬度={(label_end-label_start)/86400:.1f}天)") # 判斷是否為近日期事件 is_close_date = False if last_event_seconds is not None: time_diff = abs(event_seconds - last_event_seconds) is_close_date = time_diff <= CLOSE_DATE_THRESHOLD # 決定優先側別 preferred_side = None # 規則1: 強制交錯 - 優先使用與上一個事件不同的側別(最高優先級) if last_side is not None: preferred_side = 'lower' if last_side == 'upper' else 'upper' if is_close_date: logger.debug(f"事件 {event_idx}: 近日期 ({time_diff/86400:.1f}天),強制交錯至 {preferred_side} 側") else: logger.debug(f"事件 {event_idx}: 強制交錯至 {preferred_side} 側") # 規則2: 如果某側使用過多,加強另一側優先級 elif upper_count > lower_count + 2: preferred_side = 'lower' logger.debug(f"事件 {event_idx}: 上側過多 ({upper_count} vs {lower_count}),優先使用下側") elif lower_count > upper_count + 1: # 下方只有3個泳道,容忍度較低 preferred_side = 'upper' logger.debug(f"事件 {event_idx}: 下側過多 ({lower_count} vs {upper_count}),優先使用上側") # 根據優先側別構建搜尋順序 if preferred_side == 'upper': # 優先搜尋上側:上1→上2→上3→上4→下1→下2→下3 search_order = UPPER_LANES + LOWER_LANES elif preferred_side == 'lower': # 優先搜尋下側:下1→下2→下3→上1→上2→上3→上4 search_order = LOWER_LANES + UPPER_LANES else: # 首次事件:預設從上側開始 search_order = UPPER_LANES + LOWER_LANES logger.debug(f"事件 {event_idx}: 首個事件,從上側開始") # v9.5: 改進算法 - 標籤完全不得重疊(最高優先級) # 策略: # 1. 優先搜尋首選側別 # 2. 只考慮「標籤無重疊」的泳道(overlap_score = 0) # 3. 如果首選側別沒有無重疊泳道,則嘗試另一側 # 4. 如果所有泳道都有重疊,擴展到新泳道(未實現)或報錯 best_lane = None min_conflicts = float('inf') found_no_overlap = False # 階段1: 只搜尋優先側別的泳道,尋找無標籤重疊的泳道 primary_lanes = UPPER_LANES if preferred_side == 'upper' else LOWER_LANES if preferred_side == 'lower' else search_order logger.info(f" 事件 {event_idx} 階段1: 搜尋 {preferred_side} 側泳道(尋找無重疊泳道)") for lane_config in primary_lanes: lane_idx = lane_config['index'] # 計算該泳道的衝突分數(分開返回標籤重疊和線穿框分數) overlap_score, line_through_box_score = self._calculate_lane_conflicts_v2( event_seconds, label_start, label_end, lane_idx, lane_config, occupied_lanes, total_seconds ) total_score = overlap_score + line_through_box_score logger.info(f" 泳道 {lane_idx} ({lane_config['side']}): " f"重疊={overlap_score:.2f}, 線穿框={line_through_box_score:.2f}, " f"總分={total_score:.2f}") # 優先級1: 標籤無重疊 if overlap_score == 0: if not found_no_overlap or total_score < min_conflicts: min_conflicts = total_score best_lane = lane_config found_no_overlap = True # 如果找到完全無衝突的泳道(無重疊且無線穿框),立即使用 if total_score == 0: logger.info(f" ✅ 找到完全無衝突泳道 {lane_idx},立即使用") break # 階段2: 如果優先側別沒有無重疊泳道,嘗試另一側 if not found_no_overlap and preferred_side in ['upper', 'lower']: fallback_lanes = LOWER_LANES if preferred_side == 'upper' else UPPER_LANES logger.info(f" 事件 {event_idx} 階段2: {preferred_side} 側無可用泳道,嘗試另一側") for lane_config in fallback_lanes: lane_idx = lane_config['index'] overlap_score, line_through_box_score = self._calculate_lane_conflicts_v2( event_seconds, label_start, label_end, lane_idx, lane_config, occupied_lanes, total_seconds ) total_score = overlap_score + line_through_box_score # 只考慮無標籤重疊的泳道 if overlap_score == 0: if not found_no_overlap or total_score < min_conflicts: min_conflicts = total_score best_lane = lane_config found_no_overlap = True if total_score == 0: logger.info(f" ✅ 找到完全無衝突泳道 {lane_idx},立即使用") break # 如果所有泳道都有重疊,記錄錯誤並使用最小重疊泳道 if not found_no_overlap: logger.error(f" ❌ 事件 {event_idx} '{event.title}': 所有泳道都有標籤重疊!使用最小衝突泳道") # best_lane 已經是衝突最小的泳道了 # 將事件分配到最佳泳道 lane_idx = best_lane['index'] # DEBUG: 記錄詳細分配資訊 overlap_check_icon = "✅" if found_no_overlap else "❌" logger.info(f" {overlap_check_icon} 事件 {event_idx} '{event.title}' -> 泳道 {lane_idx} ({best_lane['side']}), " f"總分={min_conflicts:.2f}, 優先側別={preferred_side}, 無重疊={found_no_overlap}") # 計算標籤的 Y 座標(基於泳道配置) if best_lane['side'] == 'upper': label_y = 1.0 * (best_lane['index'] + 1) # 1.0, 2.0, 3.0, 4.0 else: label_y = -1.0 * ((best_lane['index'] - 3) + 1) # -1.0, -2.0, -3.0 # 記錄佔用情況 occupied_lanes[lane_idx].append({ 'start': label_start, 'end': label_end, 'event_idx': event_idx, 'event_x': event_seconds, 'label_y': label_y }) result.append({ 'swim_lane': lane_idx, 'swim_lane_config': best_lane, 'x_offset': 0, # v9.0 暫不使用水平偏移 'label_y': label_y # 預先計算的 Y 座標 }) # 更新追蹤變數 current_side = best_lane['side'] # 統計使用次數 if current_side == 'upper': upper_count += 1 else: lower_count += 1 # 更新追蹤變數 last_event_seconds = event_seconds last_side = current_side logger.debug(f"事件 {event_idx} '{event.title}' 分配至 {current_side} 側 (泳道{lane_idx}), " f"統計: 上={upper_count}, 下={lower_count}") return result def _calculate_lane_conflicts( self, event_x: float, label_start: float, label_end: float, lane_idx: int, lane_config: Dict, occupied_lanes: Dict, total_seconds: float ) -> float: """ 計算將事件放置在特定泳道的衝突分數 考慮因素: 1. 文字框水平重疊(高權重:10.0) 2. 連接線穿過其他文字框(高權重:8.0) 3. 連接線垂直交叉(低權重:1.0) Args: event_x: 事件點X座標(秒數) label_start: 標籤起始位置(秒數) label_end: 標籤結束位置(秒數) lane_idx: 泳道索引 lane_config: 泳道配置 occupied_lanes: 已佔用的泳道資訊 total_seconds: 總時間範圍(秒數) Returns: conflict_score: 衝突分數(越低越好) """ score = 0.0 # 計算當前泳道的標籤 Y 座標和泳道高度 if lane_config['side'] == 'upper': current_label_y = 1.0 * (lane_config['index'] + 1) # 1.0, 2.0, 3.0, 4.0 else: current_label_y = -1.0 * ((lane_config['index'] - 3) + 1) # -1.0, -2.0, -3.0 # 計算連接線的中間高度(泳道高度) lane_ratio = lane_config['ratio'] mid_y = current_label_y * lane_ratio # 標籤寬度(用於碰撞檢測) label_width = total_seconds * 0.15 label_height = 1.0 # 1. 檢查同泳道的文字框水平重疊(高權重:10.0) logger.debug(f" 🔍 檢查泳道 {lane_idx} 的重疊,已佔用: {len(occupied_lanes[lane_idx])} 個位置") for occupied in occupied_lanes[lane_idx]: logger.debug(f" 比對: current[{label_start:.0f}, {label_end:.0f}] vs " f"occupied[{occupied['start']:.0f}, {occupied['end']:.0f}]") if not (label_end < occupied['start'] or label_start > occupied['end']): # 計算重疊程度 overlap_start = max(label_start, occupied['start']) overlap_end = min(label_end, occupied['end']) overlap_ratio = (overlap_end - overlap_start) / (label_end - label_start) overlap_score = 10.0 * overlap_ratio logger.warning(f" ⚠️ 發現重疊! overlap_ratio={overlap_ratio:.2f}, " f"overlap_score={overlap_score:.2f}") score += overlap_score # 重疊權重高 # 2. 檢查當前連接線是否會穿過其他已存在的文字框(超高權重:100.0) # 連接線路徑:event_x, 0 → event_x, mid_y → event_x, current_label_y # 注意:標籤位置就在 event_x(v9.0 不使用水平偏移) for other_lane_idx in range(7): for occupied in occupied_lanes[other_lane_idx]: other_event_x = occupied['event_x'] other_label_y = occupied['label_y'] # 檢查垂直線段1(event_x, 0 → event_x, mid_y)是否穿過其他文字框 if self._check_line_intersects_textbox( event_x, 0, event_x, mid_y, other_event_x, other_label_y, label_width, label_height ): score += 100.0 # 連接線穿過文字框,超高權重(最優先避免) # 檢查垂直線段2(event_x, mid_y → event_x, current_label_y)是否穿過其他文字框 if self._check_line_intersects_textbox( event_x, mid_y, event_x, current_label_y, other_event_x, other_label_y, label_width, label_height ): score += 100.0 # 連接線穿過文字框,超高權重(最優先避免) # 3. 連接線交叉檢測 - 已禁用 # 原因:強制交錯策略必然導致上下兩側連接線交叉,這是可接受的 # 保持此邏輯註釋以供參考 return score def _calculate_lane_conflicts_v2( self, event_x: float, label_start: float, label_end: float, lane_idx: int, lane_config: Dict, occupied_lanes: Dict, total_seconds: float ) -> Tuple[float, float]: """ 計算將事件放置在特定泳道的衝突分數(v9.5 版本 - 分開返回重疊和線穿框分數) 考慮因素: 1. 標籤水平重疊(權重:10.0)- 必須為0 2. 連接線穿過其他文字框(權重:100.0)- 次要優先級 Args: event_x: 事件點X座標(秒數) label_start: 標籤起始位置(秒數) label_end: 標籤結束位置(秒數) lane_idx: 泳道索引 lane_config: 泳道配置 occupied_lanes: 已佔用的泳道資訊 total_seconds: 總時間範圍(秒數) Returns: (overlap_score, line_through_box_score): - overlap_score: 標籤重疊分數(必須為0) - line_through_box_score: 連接線穿過文字框分數 """ overlap_score = 0.0 line_through_box_score = 0.0 # 計算當前泳道的標籤 Y 座標和泳道高度 if lane_config['side'] == 'upper': current_label_y = 1.0 * (lane_config['index'] + 1) # 1.0, 2.0, 3.0, 4.0 else: current_label_y = -1.0 * ((lane_config['index'] - 3) + 1) # -1.0, -2.0, -3.0 # 計算連接線的中間高度(泳道高度) lane_ratio = lane_config['ratio'] mid_y = current_label_y * lane_ratio # 標籤寬度(用於碰撞檢測) label_width = total_seconds * 0.15 label_height = 1.0 # 1. 檢查同泳道的標籤水平重疊(必須為0) for occupied in occupied_lanes[lane_idx]: if not (label_end < occupied['start'] or label_start > occupied['end']): # 計算重疊程度 overlap_start = max(label_start, occupied['start']) overlap_end = min(label_end, occupied['end']) overlap_ratio = (overlap_end - overlap_start) / (label_end - label_start) overlap_score += 10.0 * overlap_ratio # 累加重疊分數 # 2. 檢查當前連接線是否會穿過其他已存在的文字框 for other_lane_idx in range(7): for occupied in occupied_lanes[other_lane_idx]: other_event_x = occupied['event_x'] other_label_y = occupied['label_y'] # 檢查垂直線段1(event_x, 0 → event_x, mid_y)是否穿過其他文字框 if self._check_line_intersects_textbox( event_x, 0, event_x, mid_y, other_event_x, other_label_y, label_width, label_height ): line_through_box_score += 100.0 # 檢查垂直線段2(event_x, mid_y → event_x, current_label_y)是否穿過其他文字框 if self._check_line_intersects_textbox( event_x, mid_y, event_x, current_label_y, other_event_x, other_label_y, label_width, label_height ): line_through_box_score += 100.0 return overlap_score, line_through_box_score def _check_line_intersects_textbox( self, line_x1: float, line_y1: float, line_x2: float, line_y2: float, textbox_center_x: float, textbox_center_y: float, textbox_width: float, textbox_height: float ) -> bool: """ 檢測線段是否與文字框相交 Args: line_x1, line_y1: 線段起點(秒數) line_x2, line_y2: 線段終點(秒數) textbox_center_x: 文字框中心X(秒數) textbox_center_y: 文字框中心Y textbox_width: 文字框寬度(秒數) textbox_height: 文字框高度 Returns: True if 線段穿過文字框 """ # 文字框邊界 box_left = textbox_center_x - textbox_width / 2 box_right = textbox_center_x + textbox_width / 2 box_bottom = textbox_center_y - textbox_height / 2 box_top = textbox_center_y + textbox_height / 2 # 檢查水平線段(y1 == y2) if abs(line_y1 - line_y2) < 0.01: line_y = line_y1 # 檢查線段Y是否在文字框高度範圍內 if box_bottom <= line_y <= box_top: # 檢查線段X範圍是否與文字框X範圍重疊 line_x_min = min(line_x1, line_x2) line_x_max = max(line_x1, line_x2) if not (line_x_max < box_left or line_x_min > box_right): return True # 檢查垂直線段(x1 == x2) if abs(line_x1 - line_x2) < 0.01: line_x = line_x1 # 檢查線段X是否在文字框寬度範圍內 if box_left <= line_x <= box_right: # 檢查線段Y範圍是否與文字框Y範圍重疊 line_y_min = min(line_y1, line_y2) line_y_max = max(line_y1, line_y2) if not (line_y_max < box_bottom or line_y_min > box_top): return True return False def _render_connections_with_pathfinding( self, markers: List[Dict], start_date: datetime, end_date: datetime, canvas_width: int = 1200, canvas_height: int = 600 ) -> List[Dict]: """ 使用 BFS 網格路徑規劃渲染連接線(簡化版) 策略: 1. 第一輪:繪製所有不被遮擋的直線 2. 第二輪:對被遮擋的線使用BFS,終點設為標籤中心 3. 處理當前標籤時,暫時移除其障礙物 Args: markers: 標記列表 start_date: 時間範圍起始 end_date: 時間範圍結束 canvas_width: 畫布寬度 canvas_height: 畫布高度 Returns: List[Dict]: Plotly shapes 列表 """ logger.info("開始使用 BFS 路徑規劃渲染連接線(簡化版)") # 計算時間範圍和標籤尺寸 time_range_seconds = (end_date - start_date).total_seconds() label_width_seconds = time_range_seconds * 0.15 # 標籤寬度(15%) label_height = 1.0 # 標籤高度 # Y 軸範圍(基於7泳道配置) y_min = -3.5 y_max = 4.5 # 自動計算網格解析度 grid_cols, grid_rows = auto_calculate_grid_resolution( num_events=len(markers), time_range_seconds=time_range_seconds, canvas_width=canvas_width, canvas_height=canvas_height, label_width_ratio=0.15 ) # 創建網格地圖 grid_map = GridMap( time_range_seconds=time_range_seconds, y_min=y_min, y_max=y_max, grid_cols=grid_cols, grid_rows=grid_rows, time_start=start_date ) logger.info(f"網格地圖創建完成: {grid_cols}×{grid_rows}") logger.info(f"標籤尺寸: 寬度={label_width_seconds:.0f}秒, 高度={label_height:.2f}") # 排序標記(從左到右) sorted_markers = sorted(markers, key=lambda m: m['event_x']) shapes = [] skipped_markers = [] # 需要 BFS 處理的標記 # ============ 第一輪:檢測並繪製直線 ============ logger.info(f"第一輪:檢測 {len(markers)} 個事件的碰撞情況") for idx, marker in enumerate(sorted_markers): event_x = marker['event_x'] label_x = marker['label_x'] label_y = marker['label_y'] color = marker['color'] title = marker.get('title', f'Event {idx}') # 連接線的起點和終點 line_x1_ts = event_x.timestamp() line_y1 = 0 line_x2_ts = label_x.timestamp() line_y2 = label_y # 判斷是否為垂直線 is_vertical_line = abs(line_x2_ts - line_x1_ts) < 1e-6 # 檢查是否與其他標籤相交 line_blocked = False blocking_labels = [] # 垂直線跳過碰撞檢測 if is_vertical_line: logger.debug(f" '{title}' 是垂直線,跳過碰撞檢測") line_blocked = False # 只對非垂直線進行碰撞檢測 for j, other in enumerate(sorted_markers) if not is_vertical_line else []: if j == idx: continue # 跳過自己 other_title = other.get('title', f'Event {j}') other_x_ts = other['label_x'].timestamp() other_y = other['label_y'] # 計算其他標籤的矩形邊界 other_left = other_x_ts - label_width_seconds / 2 other_right = other_x_ts + label_width_seconds / 2 other_top = other_y + label_height / 2 other_bottom = other_y - label_height / 2 # 檢測線段與矩形的相交 # 1. 首先檢查X範圍是否重疊 line_x_min = min(line_x1_ts, line_x2_ts) line_x_max = max(line_x1_ts, line_x2_ts) if line_x_max < other_left or line_x_min > other_right: continue # 2. 計算線段在標籤X範圍內的Y值 if abs(line_x2_ts - line_x1_ts) < 1e-6: if other_left <= line_x1_ts <= other_right: line_y_min = min(line_y1, line_y2) line_y_max = max(line_y1, line_y2) if not (line_y_max < other_bottom or line_y_min > other_top): line_blocked = True blocking_labels.append(other_title) else: intersects = False intersection_reason = "" # 1. 檢查線段起點和終點是否在標籤內 if (other_left <= line_x1_ts <= other_right and other_bottom <= line_y1 <= other_top): intersects = True intersection_reason = "起點在標籤內" elif (other_left <= line_x2_ts <= other_right and other_bottom <= line_y2 <= other_top): intersects = True intersection_reason = "終點在標籤內" # 2. 檢查線段是否與標籤的四條邊相交 if not intersects and line_x1_ts != line_x2_ts: if line_x_min <= other_left <= line_x_max: t = (other_left - line_x1_ts) / (line_x2_ts - line_x1_ts) y_at_left = line_y1 + t * (line_y2 - line_y1) if other_bottom <= y_at_left <= other_top: intersects = True intersection_reason = f"穿過左邊界" if not intersects and line_x_min <= other_right <= line_x_max: t = (other_right - line_x1_ts) / (line_x2_ts - line_x1_ts) y_at_right = line_y1 + t * (line_y2 - line_y1) if other_bottom <= y_at_right <= other_top: intersects = True intersection_reason = f"穿過右邊界" # 3. 檢查線段是否與標籤的上下邊界相交 if not intersects and abs(line_y2 - line_y1) > 1e-6: t_bottom = (other_bottom - line_y1) / (line_y2 - line_y1) if 0 <= t_bottom <= 1: x_at_bottom = line_x1_ts + t_bottom * (line_x2_ts - line_x1_ts) if other_left <= x_at_bottom <= other_right: intersects = True intersection_reason = f"穿過下邊界" if not intersects: t_top = (other_top - line_y1) / (line_y2 - line_y1) if 0 <= t_top <= 1: x_at_top = line_x1_ts + t_top * (line_x2_ts - line_x1_ts) if other_left <= x_at_top <= other_right: intersects = True intersection_reason = f"穿過上邊界" if intersects: line_blocked = True blocking_labels.append(other_title) if not line_blocked: # 直線不被遮擋,直接繪製 logger.info(f" ✓ '{title}' 使用直線連接") shapes.append({ 'type': 'line', 'x0': event_x, 'y0': 0, 'x1': label_x, 'y1': label_y, 'xref': 'x', 'yref': 'y', 'line': {'color': color, 'width': 1.5}, 'layer': 'below', 'opacity': 0.7 }) # 將這條直線標記為障礙物 path_points = [(event_x, 0), (label_x, label_y)] grid_map.mark_path(path_points, width_expansion=2.5) else: # 被遮擋,留待第二輪處理 logger.info(f" ✗ '{title}' 被 {set(blocking_labels)} 遮擋,需要BFS") skipped_markers.append(marker) logger.info(f"第一輪完成:{len(markers) - len(skipped_markers)} 條直線," f"{len(skipped_markers)} 條需要 BFS") # ============ 第二輪:BFS 處理被遮擋的連接線 ============ if len(skipped_markers) > 0: logger.info(f"第二輪:使用 BFS 處理 {len(skipped_markers)} 條被遮擋的連接線") # 先標記所有標籤為障礙物(不外擴,使用實際大小) for marker in sorted_markers: grid_map.mark_rectangle( center_x_datetime=marker['label_x'], center_y=marker['label_y'], width_seconds=label_width_seconds, height=label_height, state=GridMap.OBSTACLE, expansion_ratio=0.0 # 不外擴,避免過度阻擋 ) # 處理每條被遮擋的連接線 for idx, marker in enumerate(skipped_markers): event_x = marker['event_x'] label_x = marker['label_x'] label_y = marker['label_y'] color = marker['color'] title = marker.get('title', 'Unknown') logger.info(f" 處理 [{idx+1}/{len(skipped_markers)}] '{title}'") # 暫時清除當前標籤的障礙物(關鍵改進!) grid_map.mark_rectangle( center_x_datetime=label_x, center_y=label_y, width_seconds=label_width_seconds, height=label_height, state=GridMap.FREE, # 暫時設為自由 expansion_ratio=0.0 # 不外擴 ) # 如果標籤與事件在同一時間(垂直對齊),清除事件點附近 if abs((label_x - event_x).total_seconds()) < label_width_seconds / 4: start_clear_seconds = 3600 grid_map.mark_rectangle( center_x_datetime=event_x, center_y=0, width_seconds=start_clear_seconds, height=0.5, state=GridMap.FREE, expansion_ratio=0 ) # 起點:事件點(時間軸上) start_col = grid_map.datetime_to_grid_x(event_x) start_row = grid_map.y_to_grid_y(0) # 終點:標籤邊緣 if label_y > 0: label_edge_y = label_y - label_height / 2 direction_constraint = "up" else: label_edge_y = label_y + label_height / 2 direction_constraint = "down" end_col = grid_map.datetime_to_grid_x(label_x) end_row = grid_map.y_to_grid_y(label_edge_y) logger.debug(f" 起點網格: ({start_row},{start_col}), " f"終點網格: ({end_row},{end_col}), " f"標籤Y={label_y:.2f}, 邊緣Y={label_edge_y:.2f}, " f"方向: {direction_constraint}") # BFS 尋路 path_grid = find_path_bfs( start_row=start_row, start_col=start_col, end_row=end_row, end_col=end_col, grid_map=grid_map, direction_constraint=direction_constraint ) if path_grid is None: logger.warning(f" BFS 找不到路徑,使用直線") shapes.append({ 'type': 'line', 'x0': event_x, 'y0': 0, 'x1': label_x, 'y1': label_y, 'xref': 'x', 'yref': 'y', 'line': {'color': color, 'width': 1.5, 'dash': 'dot'}, 'layer': 'below', 'opacity': 0.5 }) path_points = [(event_x, 0), (label_x, label_y)] grid_map.mark_path(path_points, width_expansion=2.5) else: logger.info(f" BFS 找到路徑,長度: {len(path_grid)}") # 簡化路徑 path_coords = simplify_path(path_grid, grid_map) # 繪製路徑(多段線) for i in range(len(path_coords) - 1): dt1, y1 = path_coords[i] dt2, y2 = path_coords[i + 1] shapes.append({ 'type': 'line', 'x0': dt1, 'y0': y1, 'x1': dt2, 'y1': y2, 'xref': 'x', 'yref': 'y', 'line': {'color': color, 'width': 1.5}, 'layer': 'below', 'opacity': 0.7 }) # 將路徑標記為障礙物 grid_map.mark_path(path_coords, width_expansion=2.5) # 恢復當前標籤為障礙物 grid_map.mark_rectangle( center_x_datetime=label_x, center_y=label_y, width_seconds=label_width_seconds, height=label_height, state=GridMap.OBSTACLE, expansion_ratio=0.0 ) logger.info(f"BFS 路徑規劃完成,共生成 {len(shapes)} 個線段") return shapes def render(self, events: List[Event], config: TimelineConfig) -> RenderResult: """ 渲染經典時間軸 Args: events: 事件列表 config: 配置參數 Returns: RenderResult: 渲染結果 """ if not events: return self._render_empty_timeline(config) # 排序事件(按開始時間) sorted_events = sorted(events, key=lambda e: e.start) # 獲取主題 theme = self.THEMES.get(config.theme, self.THEMES[ThemeStyle.MODERN]) # 計算時間範圍 min_date = min(e.start for e in sorted_events) max_date = max((e.end if e.end else e.start) for e in sorted_events) time_span = (max_date - min_date).days margin_days = max(time_span * 0.1, 1) start_date = min_date - timedelta(days=margin_days) end_date = max_date + timedelta(days=margin_days) # 根據方向選擇渲染方式 if config.direction == 'horizontal': return self._render_horizontal(sorted_events, config, theme, start_date, end_date) else: return self._render_vertical(sorted_events, config, theme, start_date, end_date) def _render_horizontal( self, events: List[Event], config: TimelineConfig, theme: ThemeStyle, start_date: datetime, end_date: datetime ) -> RenderResult: """渲染水平時間軸""" # 主軸線 y=0 axis_y = 0 # 計算智能標籤位置(v9.0 - 固定5泳道 + 貪婪算法) label_positions = self._calculate_label_positions(events, start_date, end_date) # 時間範圍(用於計算水平偏移) time_range_seconds = (end_date - start_date).total_seconds() # 準備數據 markers = [] # 為每個事件分配位置 for i, event in enumerate(events): event_date = event.start pos_info = label_positions[i] swim_lane = pos_info['swim_lane'] swim_lane_config = pos_info['swim_lane_config'] x_offset_ratio = pos_info['x_offset'] label_y = pos_info['label_y'] # v9.0 使用預先計算的 Y 座標 # 計算水平偏移(以 timedelta 表示) x_offset_seconds = x_offset_ratio * time_range_seconds label_x = event.start + timedelta(seconds=x_offset_seconds) # 準備顯示文字(包含時間、標題、描述) datetime_str = event.start.strftime('%Y-%m-%d %H:%M:%S') # 文字框內容:時間 + 粗體標題 + 描述 display_text = f"{datetime_str}
" display_text += f"{event.title}" if event.description: display_text += f"
{event.description}" # 懸停提示(簡化版) hover_text = f"{event.title}
時間: {datetime_str}" if event.description: hover_text += f"
{event.description}" markers.append({ 'event_x': event_date, # 事件點在主軸上的位置 'label_x': label_x, # 標籤的 x 位置(可能有偏移) 'y': axis_y, 'label_y': label_y, # v9.0 使用預先計算的 Y 座標 'text': display_text, 'hover': hover_text, 'color': event.color if event.color else theme['event_colors'][i % len(theme['event_colors'])], 'swim_lane': swim_lane, 'swim_lane_config': swim_lane_config }) # 創建 Plotly 數據結構 data = [] shapes = [] annotations = [] # 1. 主時間軸線 shapes.append({ 'type': 'line', 'x0': start_date, 'y0': axis_y, 'x1': end_date, 'y1': axis_y, 'line': { 'color': theme['line_color'], 'width': 3 } }) # 2. 事件點 for marker in markers: # 事件圓點 data.append({ 'type': 'scatter', 'x': [marker['event_x']], 'y': [marker['y']], 'mode': 'markers', 'marker': { 'size': 10, 'color': marker['color'], 'line': {'color': '#FFFFFF', 'width': 2} }, 'hovertemplate': marker['hover'] + '', 'showlegend': False, 'name': '' }) # 3. 使用 BFS 網格路徑規劃渲染連接線(替換舊的多段線段繞行邏輯) connection_shapes = self._render_connections_with_pathfinding( markers=markers, start_date=start_date, end_date=end_date, canvas_width=1200, canvas_height=600 ) shapes.extend(connection_shapes) # 4. 文字標註(包含時間、標題、描述) for marker in markers: # 文字標註(包含時間、標題、描述) annotations.append({ 'x': marker['label_x'], 'y': marker['label_y'], 'text': marker['text'], 'showarrow': False, 'font': { 'size': 10, 'color': theme['text_color'] }, 'bgcolor': 'rgba(255, 255, 255, 0.85)', 'bordercolor': marker['color'], 'borderwidth': 2, 'borderpad': 5, 'yshift': 10 if marker['label_y'] > 0 else -10, 'align': 'left' }) # 計算 Y 軸範圍 y_range_max = 4.5 y_range_min = -2.5 y_margin = 0.8 # 佈局配置 layout = { 'title': { 'text': '時間軸', 'font': {'size': 20, 'color': theme['text_color']} }, 'xaxis': { 'title': '時間', 'type': 'date', 'showgrid': config.show_grid, 'gridcolor': theme['grid_color'], 'range': [start_date, end_date] }, 'yaxis': { 'visible': False, 'range': [y_range_min - y_margin, y_range_max + y_margin] }, 'shapes': shapes, 'annotations': annotations, 'plot_bgcolor': theme['background_color'], 'paper_bgcolor': theme['background_color'], 'hovermode': 'closest', 'showlegend': False, 'height': 600, 'margin': {'l': 50, 'r': 50, 't': 80, 'b': 80} } plotly_config = { 'responsive': True, 'displayModeBar': True, 'displaylogo': False, 'modeBarButtonsToRemove': ['lasso2d', 'select2d'], } if config.enable_zoom: plotly_config['scrollZoom'] = True if config.enable_drag: plotly_config['dragmode'] = 'pan' return RenderResult( success=True, data={'data': data}, layout=layout, config=plotly_config, message=f"成功渲染 {len(events)} 個事件" ) def _render_vertical( self, events: List[Event], config: TimelineConfig, theme: ThemeStyle, start_date: datetime, end_date: datetime ) -> RenderResult: """渲染垂直時間軸""" # 主軸線 x=0 axis_x = 0 # 計算智能標籤位置 label_positions = self._calculate_label_positions(events, start_date, end_date) max_layer = max(pos['layer'] for pos in label_positions) if label_positions else 0 # 每層的水平間距 layer_spacing = 1.0 # 增加間距以容納更大的文字框(時間+標題+描述) # 時間範圍 time_range_seconds = (end_date - start_date).total_seconds() # 準備數據 markers = [] # 為每個事件分配位置 for i, event in enumerate(events): event_date = event.start pos_info = label_positions[i] layer = pos_info['layer'] y_offset_ratio = pos_info['x_offset'] # 對於垂直時間軸,這是 y 軸偏移 # 根據層級決定左右位置 # 偶數層在右側,奇數層在左側 if layer % 2 == 0: x_pos = (layer // 2 + 1) * layer_spacing # 右側 else: x_pos = -((layer // 2 + 1) * layer_spacing) # 左側 # 計算垂直偏移 y_offset_seconds = y_offset_ratio * time_range_seconds label_y = event.start + timedelta(seconds=y_offset_seconds) # 準備顯示文字(包含時間、標題、描述) datetime_str = event.start.strftime('%Y-%m-%d %H:%M:%S') # 文字框內容:時間 + 粗體標題 + 描述 display_text = f"{datetime_str}
" display_text += f"{event.title}" if event.description: display_text += f"
{event.description}" # 懸停提示(簡化版) hover_text = f"{event.title}
時間: {datetime_str}" if event.description: hover_text += f"
{event.description}" markers.append({ 'event_y': event_date, # 事件點在主軸上的位置 'label_y': label_y, # 標籤的 y 位置(可能有偏移) 'x': axis_x, 'label_x': x_pos, 'text': display_text, 'hover': hover_text, 'color': event.color if event.color else theme['event_colors'][i % len(theme['event_colors'])], 'layer': layer }) # 創建 Plotly 數據結構 data = [] shapes = [] annotations = [] # 1. 主時間軸線 shapes.append({ 'type': 'line', 'x0': axis_x, 'y0': start_date, 'x1': axis_x, 'y1': end_date, 'line': { 'color': theme['line_color'], 'width': 3 } }) # 2. 事件點、時間標籤和連接線 for marker in markers: # 事件圓點 data.append({ 'type': 'scatter', 'x': [marker['x']], 'y': [marker['event_y']], 'mode': 'markers', 'marker': { 'size': 12, 'color': marker['color'], 'line': {'color': '#FFFFFF', 'width': 2} }, 'hovertemplate': marker['hover'] + '', 'showlegend': False, 'name': marker['text'] }) # 連接線:直線或直角折線 event_y = marker['event_y'] label_x = marker['label_x'] label_y = marker['label_y'] layer = marker['layer'] # 檢查是否正好在正左側或正右側(y 座標相同) y_diff_seconds = abs((label_y - event_y).total_seconds()) is_directly_sideways = y_diff_seconds < 60 # 小於 1 分鐘視為正側方 if is_directly_sideways: # 使用直線段 line_x_points = [marker['x'], label_x] line_y_points = [event_y, event_y] else: # 使用 L 形直角折線 is_right_side = label_x > 0 total_range = (end_date - start_date).total_seconds() y_span_ratio = abs(y_diff_seconds) / total_range if total_range > 0 else 0 layer_group = layer % 10 if is_right_side: base_ratio = 0.25 layer_offset = layer_group * 0.06 else: base_ratio = 0.85 layer_offset = -layer_group * 0.06 if y_span_ratio > 0.3: distance_adjustment = -0.10 if is_right_side else 0.10 elif y_span_ratio > 0.15: distance_adjustment = -0.05 if is_right_side else 0.05 else: distance_adjustment = 0 mid_x_ratio = base_ratio + layer_offset + distance_adjustment mid_x_ratio = max(0.20, min(mid_x_ratio, 0.90)) mid_x = label_x * mid_x_ratio line_x_points = [ marker['x'], # 起點 mid_x, # 水平移動到中間寬度(智能計算) mid_x, # 垂直移動 label_x # 水平到標籤 ] line_y_points = [ event_y, # 起點 event_y, # 保持在同一高度 label_y, # 垂直移動到標籤 y label_y ] # 繪製連接線 for i in range(len(line_x_points) - 1): shapes.append({ 'type': 'line', 'x0': line_x_points[i], 'y0': line_y_points[i], 'x1': line_x_points[i + 1], 'y1': line_y_points[i + 1], 'xref': 'x', 'yref': 'y', 'line': { 'color': marker['color'], 'width': 1.5, }, 'layer': 'below', 'opacity': 0.7, }) # 文字標註(包含時間、標題、描述) annotations.append({ 'x': marker['label_x'], 'y': marker['label_y'], 'text': marker['text'], 'showarrow': False, 'font': { 'size': 10, 'color': theme['text_color'] }, 'bgcolor': 'rgba(255, 255, 255, 0.85)', 'bordercolor': marker['color'], 'borderwidth': 2, 'borderpad': 5, 'xshift': 15 if marker['label_x'] > 0 else -15, 'align': 'left' }) # 計算 X 軸範圍 x_range_max = max((pos['layer'] // 2 + 1) * layer_spacing for pos in label_positions) if label_positions else layer_spacing x_range_min = -x_range_max x_margin = 0.4 # 佈局配置 layout = { 'title': { 'text': '時間軸', 'font': {'size': 20, 'color': theme['text_color']} }, 'yaxis': { 'title': '時間', 'type': 'date', 'showgrid': config.show_grid, 'gridcolor': theme['grid_color'], 'range': [start_date, end_date] }, 'xaxis': { 'visible': False, 'range': [x_range_min - x_margin, x_range_max + x_margin] }, 'shapes': shapes, 'annotations': annotations, 'plot_bgcolor': theme['background_color'], 'paper_bgcolor': theme['background_color'], 'hovermode': 'closest', 'showlegend': False, 'height': 800, 'margin': {'l': 100, 'r': 100, 't': 80, 'b': 50} } plotly_config = { 'responsive': True, 'displayModeBar': True, 'displaylogo': False, 'modeBarButtonsToRemove': ['lasso2d', 'select2d'], } if config.enable_zoom: plotly_config['scrollZoom'] = True if config.enable_drag: plotly_config['dragmode'] = 'pan' return RenderResult( success=True, data={'data': data}, layout=layout, config=plotly_config, message=f"成功渲染 {len(events)} 個事件" ) def _render_empty_timeline(self, config: TimelineConfig) -> RenderResult: """渲染空白時間軸""" theme = self.THEMES.get(config.theme, self.THEMES[ThemeStyle.MODERN]) data = [] layout = { 'title': '時間軸(無事件)', 'plot_bgcolor': theme['background_color'], 'paper_bgcolor': theme['background_color'], 'xaxis': {'visible': False}, 'yaxis': {'visible': False}, 'annotations': [{ 'text': '尚無事件資料', 'xref': 'paper', 'yref': 'paper', 'x': 0.5, 'y': 0.5, 'showarrow': False, 'font': {'size': 20, 'color': theme['text_color']} }] } return RenderResult( success=True, data={'data': data}, layout=layout, config={'responsive': True}, message="空白時間軸" )