Files
Timeline_Generator/backend/renderer_timeline.py
beabigegg aa987adfb9 後端代碼清理:移除冗餘註解和調試代碼
清理內容:
- 移除所有開發元資訊(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>
2025-11-06 12:22:29 +08:00

1542 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
經典時間軸渲染器
創建傳統的時間軸風格:
- 一條水平/垂直主軸線
- 事件點標記
- 交錯的文字標註
- 連接線
"""
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_xv9.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']
# 檢查垂直線段1event_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 # 連接線穿過文字框,超高權重(最優先避免)
# 檢查垂直線段2event_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']
# 檢查垂直線段1event_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
# 檢查垂直線段2event_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="空白時間軸"
)