""" 時間軸渲染模組 本模組負責將事件資料轉換為視覺化的時間軸圖表。 使用 Plotly 進行渲染,支援時間刻度自動調整與節點避碰。 """ 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: """ 時間刻度計算器 根據事件的時間跨度自動選擇最適合的刻度單位與間隔。 """ @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: """ 節點避碰解析器 處理時間軸上重疊事件的排版,確保事件不會相互覆蓋。 """ 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"{event.title}"] 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 '
'.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']