- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
567 lines
16 KiB
Python
567 lines
16 KiB
Python
"""
|
||
時間軸渲染模組
|
||
|
||
本模組負責將事件資料轉換為視覺化的時間軸圖表。
|
||
使用 Plotly 進行渲染,支援時間刻度自動調整與節點避碰。
|
||
|
||
Author: AI Agent
|
||
Version: 1.0.0
|
||
DocID: SDD-REN-001
|
||
Related: TDD-UT-REN-001, TDD-UT-REN-002
|
||
Rationale: 實現 SDD.md 定義的 POST /render API 功能
|
||
"""
|
||
|
||
from datetime import datetime, timedelta
|
||
from typing import List, Dict, Any, Tuple, Optional
|
||
from enum import Enum
|
||
import logging
|
||
|
||
from .schemas import Event, TimelineConfig, RenderResult, ThemeStyle
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class TimeUnit(str, Enum):
|
||
"""時間刻度單位"""
|
||
HOUR = "hour"
|
||
DAY = "day"
|
||
WEEK = "week"
|
||
MONTH = "month"
|
||
QUARTER = "quarter"
|
||
YEAR = "year"
|
||
|
||
|
||
class TimeScaleCalculator:
|
||
"""
|
||
時間刻度計算器
|
||
|
||
根據事件的時間跨度自動選擇最適合的刻度單位與間隔。
|
||
對應 TDD.md - UT-REN-01
|
||
"""
|
||
|
||
@staticmethod
|
||
def calculate_time_range(events: List[Event]) -> Tuple[datetime, datetime]:
|
||
"""
|
||
計算事件的時間範圍
|
||
|
||
Args:
|
||
events: 事件列表
|
||
|
||
Returns:
|
||
(最早時間, 最晚時間)
|
||
"""
|
||
if not events:
|
||
now = datetime.now()
|
||
return now, now + timedelta(days=30)
|
||
|
||
min_time = min(event.start for event in events)
|
||
max_time = max(
|
||
event.end if event.end else event.start
|
||
for event in events
|
||
)
|
||
|
||
# 添加一些邊距(10%)
|
||
time_span = max_time - min_time
|
||
margin = time_span * 0.1 if time_span.total_seconds() > 0 else timedelta(days=1)
|
||
|
||
return min_time - margin, max_time + margin
|
||
|
||
@staticmethod
|
||
def determine_time_unit(start: datetime, end: datetime) -> TimeUnit:
|
||
"""
|
||
根據時間跨度決定刻度單位
|
||
|
||
Args:
|
||
start: 開始時間
|
||
end: 結束時間
|
||
|
||
Returns:
|
||
最適合的時間單位
|
||
"""
|
||
time_span = end - start
|
||
days = time_span.days
|
||
|
||
if days <= 2:
|
||
return TimeUnit.HOUR
|
||
elif days <= 31:
|
||
return TimeUnit.DAY
|
||
elif days <= 90:
|
||
return TimeUnit.WEEK
|
||
elif days <= 730: # 2 年
|
||
return TimeUnit.MONTH
|
||
elif days <= 1825: # 5 年
|
||
return TimeUnit.QUARTER
|
||
else:
|
||
return TimeUnit.YEAR
|
||
|
||
@staticmethod
|
||
def generate_tick_values(start: datetime, end: datetime, unit: TimeUnit) -> List[datetime]:
|
||
"""
|
||
生成刻度值列表
|
||
|
||
Args:
|
||
start: 開始時間
|
||
end: 結束時間
|
||
unit: 時間單位
|
||
|
||
Returns:
|
||
刻度時間點列表
|
||
"""
|
||
ticks = []
|
||
current = start
|
||
|
||
if unit == TimeUnit.HOUR:
|
||
# 每小時一個刻度
|
||
current = current.replace(minute=0, second=0, microsecond=0)
|
||
while current <= end:
|
||
ticks.append(current)
|
||
current += timedelta(hours=1)
|
||
|
||
elif unit == TimeUnit.DAY:
|
||
# 每天一個刻度
|
||
current = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
while current <= end:
|
||
ticks.append(current)
|
||
current += timedelta(days=1)
|
||
|
||
elif unit == TimeUnit.WEEK:
|
||
# 每週一個刻度(週一)
|
||
current = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
days_to_monday = current.weekday()
|
||
current -= timedelta(days=days_to_monday)
|
||
while current <= end:
|
||
ticks.append(current)
|
||
current += timedelta(weeks=1)
|
||
|
||
elif unit == TimeUnit.MONTH:
|
||
# 每月一個刻度(月初)
|
||
current = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
while current <= end:
|
||
ticks.append(current)
|
||
# 移到下個月
|
||
if current.month == 12:
|
||
current = current.replace(year=current.year + 1, month=1)
|
||
else:
|
||
current = current.replace(month=current.month + 1)
|
||
|
||
elif unit == TimeUnit.QUARTER:
|
||
# 每季一個刻度
|
||
current = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
quarter_month = ((current.month - 1) // 3) * 3 + 1
|
||
current = current.replace(month=quarter_month)
|
||
while current <= end:
|
||
ticks.append(current)
|
||
# 移到下一季
|
||
new_month = current.month + 3
|
||
if new_month > 12:
|
||
current = current.replace(year=current.year + 1, month=new_month - 12)
|
||
else:
|
||
current = current.replace(month=new_month)
|
||
|
||
elif unit == TimeUnit.YEAR:
|
||
# 每年一個刻度
|
||
current = current.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
while current <= end:
|
||
ticks.append(current)
|
||
current = current.replace(year=current.year + 1)
|
||
|
||
return ticks
|
||
|
||
|
||
class CollisionResolver:
|
||
"""
|
||
節點避碰解析器
|
||
|
||
處理時間軸上重疊事件的排版,確保事件不會相互覆蓋。
|
||
對應 TDD.md - UT-REN-02
|
||
"""
|
||
|
||
def __init__(self, min_spacing: int = 10):
|
||
"""
|
||
Args:
|
||
min_spacing: 最小間距(像素)
|
||
"""
|
||
self.min_spacing = min_spacing
|
||
|
||
def resolve_collisions(self, events: List[Event]) -> Dict[str, int]:
|
||
"""
|
||
解決事件碰撞,分配 Y 軸位置(層級)
|
||
|
||
Args:
|
||
events: 事件列表
|
||
|
||
Returns:
|
||
事件 ID 到層級的映射 {event_id: layer}
|
||
"""
|
||
if not events:
|
||
return {}
|
||
|
||
# 按開始時間排序
|
||
sorted_events = sorted(events, key=lambda e: (e.start, e.end or e.start))
|
||
|
||
# 儲存每層的最後結束時間
|
||
layers: List[Optional[datetime]] = []
|
||
event_layers: Dict[str, int] = {}
|
||
|
||
for event in sorted_events:
|
||
event_end = event.end if event.end else event.start + timedelta(hours=1)
|
||
|
||
# 尋找可以放置的層級
|
||
placed = False
|
||
for layer_idx, layer_end_time in enumerate(layers):
|
||
if layer_end_time is None or event.start >= layer_end_time:
|
||
# 這層可以放置
|
||
event_layers[event.id] = layer_idx
|
||
layers[layer_idx] = event_end
|
||
placed = True
|
||
break
|
||
|
||
if not placed:
|
||
# 需要新增一層
|
||
layer_idx = len(layers)
|
||
event_layers[event.id] = layer_idx
|
||
layers.append(event_end)
|
||
|
||
return event_layers
|
||
|
||
def group_based_layout(self, events: List[Event]) -> Dict[str, int]:
|
||
"""
|
||
基於群組的排版
|
||
|
||
同組事件優先排在一起。
|
||
|
||
Args:
|
||
events: 事件列表
|
||
|
||
Returns:
|
||
事件 ID 到層級的映射
|
||
"""
|
||
if not events:
|
||
return {}
|
||
|
||
# 按群組分組
|
||
groups: Dict[str, List[Event]] = {}
|
||
for event in events:
|
||
group_key = event.group if event.group else "_default_"
|
||
if group_key not in groups:
|
||
groups[group_key] = []
|
||
groups[group_key].append(event)
|
||
|
||
# 為每個群組分配層級
|
||
event_layers: Dict[str, int] = {}
|
||
current_layer = 0
|
||
|
||
for group_key, group_events in groups.items():
|
||
# 在群組內解決碰撞
|
||
group_layers = self.resolve_collisions(group_events)
|
||
|
||
# 將群組層級加上偏移量
|
||
max_layer_in_group = max(group_layers.values()) if group_layers else 0
|
||
|
||
for event_id, layer in group_layers.items():
|
||
event_layers[event_id] = current_layer + layer
|
||
|
||
current_layer += max_layer_in_group + 1
|
||
|
||
return event_layers
|
||
|
||
|
||
class ThemeManager:
|
||
"""
|
||
主題管理器
|
||
|
||
管理不同的視覺主題。
|
||
"""
|
||
|
||
THEMES = {
|
||
ThemeStyle.MODERN: {
|
||
'background': '#FFFFFF',
|
||
'text': '#1F2937',
|
||
'grid': '#E5E7EB',
|
||
'primary': '#3B82F6',
|
||
'font_family': 'Arial, sans-serif',
|
||
},
|
||
ThemeStyle.CLASSIC: {
|
||
'background': '#F9FAFB',
|
||
'text': '#374151',
|
||
'grid': '#D1D5DB',
|
||
'primary': '#6366F1',
|
||
'font_family': 'Georgia, serif',
|
||
},
|
||
ThemeStyle.MINIMAL: {
|
||
'background': '#FFFFFF',
|
||
'text': '#000000',
|
||
'grid': '#CCCCCC',
|
||
'primary': '#000000',
|
||
'font_family': 'Helvetica, sans-serif',
|
||
},
|
||
ThemeStyle.CORPORATE: {
|
||
'background': '#F3F4F6',
|
||
'text': '#111827',
|
||
'grid': '#9CA3AF',
|
||
'primary': '#1F2937',
|
||
'font_family': 'Calibri, sans-serif',
|
||
},
|
||
}
|
||
|
||
@classmethod
|
||
def get_theme(cls, theme_style: ThemeStyle) -> Dict[str, str]:
|
||
"""
|
||
獲取主題配置
|
||
|
||
Args:
|
||
theme_style: 主題樣式
|
||
|
||
Returns:
|
||
主題配置字典
|
||
"""
|
||
return cls.THEMES.get(theme_style, cls.THEMES[ThemeStyle.MODERN])
|
||
|
||
|
||
class TimelineRenderer:
|
||
"""
|
||
時間軸渲染器
|
||
|
||
負責將事件資料轉換為 Plotly JSON 格式。
|
||
"""
|
||
|
||
def __init__(self):
|
||
self.scale_calculator = TimeScaleCalculator()
|
||
self.collision_resolver = CollisionResolver()
|
||
self.theme_manager = ThemeManager()
|
||
|
||
def render(self, events: List[Event], config: TimelineConfig) -> RenderResult:
|
||
"""
|
||
渲染時間軸
|
||
|
||
Args:
|
||
events: 事件列表
|
||
config: 時間軸配置
|
||
|
||
Returns:
|
||
RenderResult 物件
|
||
"""
|
||
try:
|
||
if not events:
|
||
return self._create_empty_result()
|
||
|
||
# 計算時間範圍
|
||
time_start, time_end = self.scale_calculator.calculate_time_range(events)
|
||
|
||
# 決定時間單位
|
||
time_unit = self.scale_calculator.determine_time_unit(time_start, time_end)
|
||
|
||
# 生成刻度
|
||
tick_values = self.scale_calculator.generate_tick_values(time_start, time_end, time_unit)
|
||
|
||
# 解決碰撞
|
||
if config.direction == 'horizontal':
|
||
event_layers = self.collision_resolver.resolve_collisions(events)
|
||
else:
|
||
event_layers = self.collision_resolver.group_based_layout(events)
|
||
|
||
# 獲取主題
|
||
theme = self.theme_manager.get_theme(config.theme)
|
||
|
||
# 生成 Plotly 資料
|
||
data = self._generate_plotly_data(events, event_layers, config, theme)
|
||
layout = self._generate_plotly_layout(time_start, time_end, tick_values, config, theme)
|
||
plot_config = self._generate_plotly_config(config)
|
||
|
||
return RenderResult(
|
||
success=True,
|
||
data=data,
|
||
layout=layout,
|
||
config=plot_config
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"渲染失敗: {str(e)}")
|
||
return RenderResult(
|
||
success=False,
|
||
data={},
|
||
layout={},
|
||
config={}
|
||
)
|
||
|
||
def _generate_plotly_data(
|
||
self,
|
||
events: List[Event],
|
||
event_layers: Dict[str, int],
|
||
config: TimelineConfig,
|
||
theme: Dict[str, str]
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
生成 Plotly data 部分
|
||
|
||
Args:
|
||
events: 事件列表
|
||
event_layers: 事件層級映射
|
||
config: 配置
|
||
theme: 主題
|
||
|
||
Returns:
|
||
Plotly data 字典
|
||
"""
|
||
traces = []
|
||
|
||
for event in events:
|
||
layer = event_layers.get(event.id, 0)
|
||
|
||
# 計算事件的時間範圍
|
||
start_time = event.start
|
||
end_time = event.end if event.end else event.start + timedelta(hours=1)
|
||
|
||
# 生成提示訊息
|
||
hover_text = self._generate_hover_text(event)
|
||
|
||
trace = {
|
||
'type': 'scatter',
|
||
'mode': 'lines+markers',
|
||
'x': [start_time, end_time] if config.direction == 'horizontal' else [layer, layer],
|
||
'y': [layer, layer] if config.direction == 'horizontal' else [start_time, end_time],
|
||
'name': event.title,
|
||
'line': {
|
||
'color': event.color,
|
||
'width': 10,
|
||
},
|
||
'marker': {
|
||
'size': 10,
|
||
'color': event.color,
|
||
},
|
||
'text': hover_text,
|
||
'hoverinfo': 'text' if config.show_tooltip else 'skip',
|
||
}
|
||
|
||
traces.append(trace)
|
||
|
||
return {'data': traces}
|
||
|
||
def _generate_plotly_layout(
|
||
self,
|
||
time_start: datetime,
|
||
time_end: datetime,
|
||
tick_values: List[datetime],
|
||
config: TimelineConfig,
|
||
theme: Dict[str, str]
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
生成 Plotly layout 部分
|
||
|
||
Args:
|
||
time_start: 開始時間
|
||
time_end: 結束時間
|
||
tick_values: 刻度值
|
||
config: 配置
|
||
theme: 主題
|
||
|
||
Returns:
|
||
Plotly layout 字典
|
||
"""
|
||
layout = {
|
||
'title': '時間軸',
|
||
'showlegend': True,
|
||
'hovermode': 'closest',
|
||
'plot_bgcolor': theme['background'],
|
||
'paper_bgcolor': theme['background'],
|
||
'font': {
|
||
'family': theme['font_family'],
|
||
'color': theme['text'],
|
||
},
|
||
}
|
||
|
||
if config.direction == 'horizontal':
|
||
layout['xaxis'] = {
|
||
'title': '時間',
|
||
'type': 'date',
|
||
'range': [time_start, time_end],
|
||
'tickvals': tick_values,
|
||
'showgrid': config.show_grid,
|
||
'gridcolor': theme['grid'],
|
||
}
|
||
layout['yaxis'] = {
|
||
'title': '事件層級',
|
||
'showticklabels': False,
|
||
'showgrid': False,
|
||
}
|
||
else:
|
||
layout['xaxis'] = {
|
||
'title': '事件層級',
|
||
'showticklabels': False,
|
||
'showgrid': False,
|
||
}
|
||
layout['yaxis'] = {
|
||
'title': '時間',
|
||
'type': 'date',
|
||
'range': [time_start, time_end],
|
||
'tickvals': tick_values,
|
||
'showgrid': config.show_grid,
|
||
'gridcolor': theme['grid'],
|
||
}
|
||
|
||
return layout
|
||
|
||
def _generate_plotly_config(self, config: TimelineConfig) -> Dict[str, Any]:
|
||
"""
|
||
生成 Plotly config 部分
|
||
|
||
Args:
|
||
config: 配置
|
||
|
||
Returns:
|
||
Plotly config 字典
|
||
"""
|
||
return {
|
||
'scrollZoom': config.enable_zoom,
|
||
'displayModeBar': True,
|
||
'displaylogo': False,
|
||
}
|
||
|
||
def _generate_hover_text(self, event: Event) -> str:
|
||
"""
|
||
生成事件的提示訊息
|
||
|
||
Args:
|
||
event: 事件
|
||
|
||
Returns:
|
||
提示訊息文字
|
||
"""
|
||
lines = [f"<b>{event.title}</b>"]
|
||
|
||
if event.start:
|
||
lines.append(f"開始: {event.start.strftime('%Y-%m-%d %H:%M')}")
|
||
|
||
if event.end:
|
||
lines.append(f"結束: {event.end.strftime('%Y-%m-%d %H:%M')}")
|
||
|
||
if event.group:
|
||
lines.append(f"群組: {event.group}")
|
||
|
||
if event.description:
|
||
lines.append(f"說明: {event.description}")
|
||
|
||
return '<br>'.join(lines)
|
||
|
||
def _create_empty_result(self) -> RenderResult:
|
||
"""
|
||
建立空白結果
|
||
|
||
Returns:
|
||
空白的 RenderResult
|
||
"""
|
||
return RenderResult(
|
||
success=True,
|
||
data={'data': []},
|
||
layout={
|
||
'title': '時間軸(無資料)',
|
||
'xaxis': {'title': '時間'},
|
||
'yaxis': {'title': '事件'},
|
||
},
|
||
config={}
|
||
)
|
||
|
||
|
||
# 匯出主要介面
|
||
__all__ = ['TimelineRenderer', 'RenderResult']
|