v9.5: 實作標籤完全不重疊算法

- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-11-06 11:35:29 +08:00
commit 2d37d23bcf
83 changed files with 22971 additions and 0 deletions

566
backend/renderer.py Normal file
View 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']