清理內容: - 移除所有開發元資訊(Author, Version, DocID, Rationale等) - 刪除註解掉的代碼片段(力導向演算法等24行) - 移除調試用的 logger.debug 語句 - 簡化冗餘的內聯註解(emoji、"重要"等標註) - 刪除 TDD 文件引用 清理檔案: - backend/main.py - 移除調試日誌和元資訊 - backend/importer.py - 移除詳細類型檢查調試 - backend/export.py - 簡化 docstring - backend/schemas.py - 移除元資訊 - backend/renderer.py - 移除 TDD 引用 - backend/renderer_timeline.py - 移除註解代碼和冗餘註解 - backend/path_planner.py - 簡化策略註解 保留內容: - 所有函數的 docstring(功能說明、參數、返回值) - 必要的業務邏輯註解 - 簡潔的模組功能說明 效果: - 刪除約 100+ 行冗餘註解 - 代碼更加簡潔專業 - 功能完整性 100% 保留 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1542 lines
61 KiB
Python
1542 lines
61 KiB
Python
"""
|
||
經典時間軸渲染器
|
||
|
||
創建傳統的時間軸風格:
|
||
- 一條水平/垂直主軸線
|
||
- 事件點標記
|
||
- 交錯的文字標註
|
||
- 連接線
|
||
"""
|
||
|
||
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"<span style='font-size:9px'>{datetime_str}</span><br>"
|
||
display_text += f"<b>{event.title}</b>"
|
||
if event.description:
|
||
display_text += f"<br><span style='font-size:10px'>{event.description}</span>"
|
||
|
||
# 懸停提示(簡化版)
|
||
hover_text = f"<b>{event.title}</b><br>時間: {datetime_str}"
|
||
if event.description:
|
||
hover_text += f"<br>{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'] + '<extra></extra>',
|
||
'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"<span style='font-size:9px'>{datetime_str}</span><br>"
|
||
display_text += f"<b>{event.title}</b>"
|
||
if event.description:
|
||
display_text += f"<br><span style='font-size:10px'>{event.description}</span>"
|
||
|
||
# 懸停提示(簡化版)
|
||
hover_text = f"<b>{event.title}</b><br>時間: {datetime_str}"
|
||
if event.description:
|
||
hover_text += f"<br>{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'] + '<extra></extra>',
|
||
'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="空白時間軸"
|
||
)
|