v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
566
backend/renderer.py
Normal file
566
backend/renderer.py
Normal file
@@ -0,0 +1,566 @@
|
||||
"""
|
||||
時間軸渲染模組
|
||||
|
||||
本模組負責將事件資料轉換為視覺化的時間軸圖表。
|
||||
使用 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']
|
||||
Reference in New Issue
Block a user