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

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

1633 lines
68 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.

"""
經典時間軸渲染器
創建傳統的時間軸風格:
- 一條水平/垂直主軸線
- 事件點標記
- 交錯的文字標註
- 連接線
Author: AI Agent
Version: 2.0.0
"""
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
# for other_lane_idx in range(7):
# if other_lane_idx == lane_idx:
# continue
# for occupied in occupied_lanes[other_lane_idx]:
# if not (label_end < occupied['start'] or label_start > occupied['end']):
# same_side = (current_label_y > 0 and occupied['label_y'] > 0) or \
# (current_label_y < 0 and occupied['label_y'] < 0)
# if not same_side:
# score += 1.0 # 交叉權重(已禁用)
return 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
# 🔍 判斷是否為垂直線label_x == event_x
is_vertical_line = abs(line_x2_ts - line_x1_ts) < 1e-6
# 檢查是否與其他標籤相交
line_blocked = False
blocking_labels = []
# ⚠️ 對於垂直線x_offset=0跳過碰撞檢測
# 原因:固定泳道算法已確保標籤本身不重疊,垂直線無法避開其他標籤
if is_vertical_line:
logger.debug(f" 🔹 '{title}' 是垂直線,跳過碰撞檢測,直接繪製")
line_blocked = False # 強制不使用BFS
# 只對非垂直線進行碰撞檢測
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
# 🔍 DEBUG: 記錄檢測的標籤詳情
logger.debug(f" 檢查 {title} vs {other_title}:")
logger.debug(f" {title} 線段: X1={datetime.fromtimestamp(line_x1_ts)}, Y1={line_y1:.2f} -> X2={datetime.fromtimestamp(line_x2_ts)}, Y2={line_y2:.2f}")
logger.debug(f" {other_title} 標籤: X=[{datetime.fromtimestamp(other_left)} ~ {datetime.fromtimestamp(other_right)}], Y=[{other_bottom:.2f} ~ {other_top:.2f}]")
logger.debug(f" {other_title} 泳道: {other.get('swim_lane', '?')}")
# 檢測線段與矩形的相交
# 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:
logger.debug(f" ✓ X範圍不重疊跳過")
continue # X範圍不重疊不可能相交
# 2. 計算線段在標籤X範圍內的Y值
# 使用線性插值y = y1 + (x - x1) * (y2 - y1) / (x2 - x1)
if abs(line_x2_ts - line_x1_ts) < 1e-6:
# 垂直線(幾乎不可能,但要處理)
if other_left <= line_x1_ts <= other_right:
# 檢查Y範圍
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)
logger.debug(f" 檢查左邊界: t={t:.4f}, y_at_left={y_at_left:.2f}, Y範圍=[{other_bottom:.2f}~{other_top:.2f}]")
if other_bottom <= y_at_left <= other_top:
intersects = True
intersection_reason = f"穿過左邊界 (y={y_at_left:.2f})"
# 線段與標籤右邊界的交點
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)
logger.debug(f" 檢查右邊界: t={t:.4f}, y_at_right={y_at_right:.2f}, Y範圍=[{other_bottom:.2f}~{other_top:.2f}]")
if other_bottom <= y_at_right <= other_top:
intersects = True
intersection_reason = f"穿過右邊界 (y={y_at_right:.2f})"
# 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)
logger.debug(f" 檢查下邊界: t={t_bottom:.4f}, x_at_bottom={datetime.fromtimestamp(x_at_bottom)}, X範圍=[{datetime.fromtimestamp(other_left)}~{datetime.fromtimestamp(other_right)}]")
if other_left <= x_at_bottom <= other_right:
intersects = True
intersection_reason = f"穿過下邊界 (x={datetime.fromtimestamp(x_at_bottom)})"
# 線段與標籤上邊界的交點
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)
logger.debug(f" 檢查上邊界: t={t_top:.4f}, x_at_top={datetime.fromtimestamp(x_at_top)}, X範圍=[{datetime.fromtimestamp(other_left)}~{datetime.fromtimestamp(other_right)}]")
if other_left <= x_at_top <= other_right:
intersects = True
intersection_reason = f"穿過上邊界 (x={datetime.fromtimestamp(x_at_top)})"
if intersects:
line_blocked = True
blocking_labels.append(other_title)
logger.debug(f" ❌ 碰撞確認: {intersection_reason}")
else:
logger.debug(f" ✓ 無碰撞")
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 # 不外擴
)
# 如果標籤與事件在同一時間(垂直對齊),也清除事件點附近
# 這是為了處理 Event 4 和 Event 5 這種情況
if abs((label_x - event_x).total_seconds()) < label_width_seconds / 4:
# 清除起點附近的障礙物(只清除一小塊)
start_clear_seconds = 3600 # 清除起點附近1小時的範圍
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:
# BFS 失敗,強制使用直線
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:
# BFS 成功,簡化並繪製路徑
logger.info(f" ✓ BFS 找到路徑,長度: {len(path_grid)}")
# 簡化路徑
path_coords = simplify_path(path_grid, grid_map)
logger.debug(f" 簡化後: {len(path_coords)} 個轉折點")
# 繪製路徑(多段線)
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
}
})
# 應用力導向演算法優化標籤位置(如果配置啟用)
# 暫時禁用效果不佳考慮使用專業套件D3.js, Vega-Lite
# if config.enable_zoom: # 使用 enable_zoom 作為啟用力導向的標誌(臨時)
# markers = apply_force_directed_layout(
# markers,
# config,
# time_range_seconds, # 新增:傳入時間範圍用於計算文字框尺寸
# max_iterations=100,
# repulsion_strength=50.0, # 調整:降低排斥力強度
# damping=0.8 # 調整:增加阻尼係數
# )
# 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 軸範圍v9.1 - 固定7泳道調整下層最低位置
# 上方最高為 4.0,下方最低為 -2.5 (ratio 0.50 * 5.0)
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 配置
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
}
})
# 應用力導向演算法優化標籤位置(如果配置啟用)
# 暫時禁用效果不佳考慮使用專業套件D3.js, Vega-Lite
# if config.enable_zoom: # 使用 enable_zoom 作為啟用力導向的標誌(臨時)
# markers = apply_force_directed_layout(
# markers,
# config,
# time_range_seconds, # 新增:傳入時間範圍用於計算文字框尺寸
# max_iterations=100,
# repulsion_strength=50.0, # 調整:降低排斥力強度
# damping=0.8 # 調整:增加阻尼係數
# )
# 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 形直角折線(水平 -> 垂直 -> 水平)
# 智能路徑規劃:根據層級、方向、跨越距離動態調整
# 1. 判斷標籤在左側還是右側
is_right_side = label_x > 0 # 右側為正
# 2. 計算跨越距離(標準化)
total_range = (end_date - start_date).total_seconds()
y_span_ratio = abs(y_diff_seconds) / total_range if total_range > 0 else 0
# 3. 根據層級計算基礎偏移(增加偏移幅度和範圍)
layer_group = layer % 10 # 每10層循環一次增加變化
# 4. 根據左右方向使用不同的層級策略
# 右側:從低到高 (0.25 -> 0.85)
# 左側:從高到低 (0.85 -> 0.25),鏡像分布避免交錯
if is_right_side:
base_ratio = 0.25
layer_offset = layer_group * 0.06 # 6% 增量
else:
base_ratio = 0.85
layer_offset = -layer_group * 0.06 # 負向偏移
# 5. 根據跨越距離調整
# 距離越遠,調整幅度越大
if y_span_ratio > 0.3: # 跨越超過30%的時間軸
distance_adjustment = -0.10 if is_right_side else 0.10
elif y_span_ratio > 0.15: # 跨越15-30%
distance_adjustment = -0.05 if is_right_side else 0.05
else:
distance_adjustment = 0
# 6. 計算最終的中間寬度比例
mid_x_ratio = base_ratio + layer_offset + distance_adjustment
# 7. 限制範圍,避免過遠或過近
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 # 終點
]
# 使用 shape line 繪製連接線(分段),設定 layer='below' 避免遮擋
# 將每一段連線分別繪製為獨立的 shape
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 配置
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="空白時間軸"
)