Files
Timeline_Generator/backend/renderer.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

559 lines
16 KiB
Python
Raw Permalink 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.

"""
時間軸渲染模組
本模組負責將事件資料轉換為視覺化的時間軸圖表。
使用 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"<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']