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

45
backend/__init__.py Normal file
View File

@@ -0,0 +1,45 @@
"""
TimeLine Designer Backend Package
本套件提供時間軸設計工具的後端 API 服務。
Modules:
- schemas: 資料模型定義
- importer: CSV/XLSX 匯入處理
- renderer: Plotly 時間軸渲染
- export: PDF/SVG/PNG 匯出
- main: FastAPI 主程式
Version: 1.0.0
Author: AI Agent
DocID: SDD-BACKEND-001
"""
__version__ = "1.0.0"
__author__ = "AI Agent"
from .schemas import (
Event,
EventType,
TimelineConfig,
ThemeStyle,
ExportOptions,
ExportFormat,
Theme,
ImportResult,
RenderResult,
APIResponse
)
__all__ = [
"Event",
"EventType",
"TimelineConfig",
"ThemeStyle",
"ExportOptions",
"ExportFormat",
"Theme",
"ImportResult",
"RenderResult",
"APIResponse"
]

343
backend/export.py Normal file
View File

@@ -0,0 +1,343 @@
"""
匯出模組
本模組負責將時間軸圖表匯出為各種格式PDF、PNG、SVG
使用 Plotly 的 kaleido 引擎進行圖片生成。
Author: AI Agent
Version: 1.0.0
DocID: SDD-EXP-001
Related: TDD-UT-EXP-001
Rationale: 實現 SDD.md 定義的 POST /export API 功能
"""
import os
from pathlib import Path
from datetime import datetime
from typing import Union, Optional
import logging
import re
try:
import plotly.graph_objects as go
from plotly.io import write_image
PLOTLY_AVAILABLE = True
except ImportError:
PLOTLY_AVAILABLE = False
from .schemas import ExportOptions, ExportFormat
logger = logging.getLogger(__name__)
class ExportError(Exception):
"""匯出錯誤基礎類別"""
pass
class FileNameSanitizer:
"""
檔名淨化器
移除非法字元並處理過長的檔名。
"""
# 非法字元Windows + Unix
ILLEGAL_CHARS = r'[<>:"/\\|?*\x00-\x1f]'
# 保留字Windows
RESERVED_NAMES = [
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
]
MAX_LENGTH = 200 # 最大檔名長度
@classmethod
def sanitize(cls, filename: str) -> str:
"""
淨化檔名
Args:
filename: 原始檔名
Returns:
淨化後的檔名
"""
# 移除非法字元
sanitized = re.sub(cls.ILLEGAL_CHARS, '_', filename)
# 移除前後空白
sanitized = sanitized.strip()
# 移除尾部的點和空格Windows 限制)
sanitized = sanitized.rstrip('. ')
# 檢查保留字
name_upper = sanitized.upper()
if name_upper in cls.RESERVED_NAMES:
sanitized = '_' + sanitized
# 限制長度
if len(sanitized) > cls.MAX_LENGTH:
sanitized = sanitized[:cls.MAX_LENGTH]
# 如果為空,使用預設名稱
if not sanitized:
sanitized = 'timeline'
return sanitized
class ExportEngine:
"""
匯出引擎
負責將 Plotly 圖表匯出為不同格式的檔案。
對應 TDD.md - UT-EXP-01
"""
def __init__(self):
if not PLOTLY_AVAILABLE:
raise ImportError("需要安裝 plotly 和 kaleido 以使用匯出功能")
self.filename_sanitizer = FileNameSanitizer()
def export(
self,
fig: go.Figure,
output_path: Union[str, Path],
options: ExportOptions
) -> Path:
"""
匯出圖表
Args:
fig: Plotly Figure 物件
output_path: 輸出路徑
options: 匯出選項
Returns:
實際輸出檔案的路徑
Raises:
ExportError: 匯出失敗時拋出
"""
output_path = Path(output_path)
# 確保目錄存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 淨化檔名
filename = self.filename_sanitizer.sanitize(output_path.stem)
sanitized_path = output_path.parent / f"{filename}{output_path.suffix}"
try:
if options.fmt == ExportFormat.PDF:
return self._export_pdf(fig, sanitized_path, options)
elif options.fmt == ExportFormat.PNG:
return self._export_png(fig, sanitized_path, options)
elif options.fmt == ExportFormat.SVG:
return self._export_svg(fig, sanitized_path, options)
else:
raise ExportError(f"不支援的匯出格式: {options.fmt}")
except PermissionError:
raise ExportError(f"無法寫入檔案(權限不足): {sanitized_path}")
except OSError as e:
if e.errno == 28: # ENOSPC
raise ExportError("磁碟空間不足")
else:
raise ExportError(f"檔案系統錯誤: {str(e)}")
except Exception as e:
logger.error(f"匯出失敗: {str(e)}")
raise ExportError(f"匯出失敗: {str(e)}")
def _export_pdf(self, fig: go.Figure, output_path: Path, options: ExportOptions) -> Path:
"""
匯出為 PDF
Args:
fig: Plotly Figure
output_path: 輸出路徑
options: 匯出選項
Returns:
輸出檔案路徑
"""
# 確保副檔名
if output_path.suffix.lower() != '.pdf':
output_path = output_path.with_suffix('.pdf')
# 設定 DPI 和尺寸
scale = options.dpi / 72.0 # Plotly 使用 72 DPI 作為基準
# 匯出
write_image(
fig,
str(output_path),
format='pdf',
width=options.width,
height=options.height,
scale=scale
)
logger.info(f"PDF 匯出成功: {output_path}")
return output_path
def _export_png(self, fig: go.Figure, output_path: Path, options: ExportOptions) -> Path:
"""
匯出為 PNG
Args:
fig: Plotly Figure
output_path: 輸出路徑
options: 匯出選項
Returns:
輸出檔案路徑
"""
# 確保副檔名
if output_path.suffix.lower() != '.png':
output_path = output_path.with_suffix('.png')
# 設定 DPI 和尺寸
scale = options.dpi / 72.0
# 處理透明背景
if options.transparent_background:
fig.update_layout(
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(0,0,0,0)'
)
# 匯出
write_image(
fig,
str(output_path),
format='png',
width=options.width,
height=options.height,
scale=scale
)
logger.info(f"PNG 匯出成功: {output_path}")
return output_path
def _export_svg(self, fig: go.Figure, output_path: Path, options: ExportOptions) -> Path:
"""
匯出為 SVG
Args:
fig: Plotly Figure
output_path: 輸出路徑
options: 匯出選項
Returns:
輸出檔案路徑
"""
# 確保副檔名
if output_path.suffix.lower() != '.svg':
output_path = output_path.with_suffix('.svg')
# SVG 是向量格式,不需要 DPI 設定
write_image(
fig,
str(output_path),
format='svg',
width=options.width,
height=options.height
)
logger.info(f"SVG 匯出成功: {output_path}")
return output_path
class TimelineExporter:
"""
時間軸匯出器
高層級介面,整合渲染與匯出功能。
"""
def __init__(self):
self.export_engine = ExportEngine()
def export_from_plotly_json(
self,
plotly_data: dict,
plotly_layout: dict,
output_path: Union[str, Path],
options: ExportOptions,
filename_prefix: str = "timeline"
) -> Path:
"""
從 Plotly JSON 資料匯出
Args:
plotly_data: Plotly data 部分
plotly_layout: Plotly layout 部分
output_path: 輸出路徑(目錄或完整路徑)
options: 匯出選項
filename_prefix: 檔名前綴
Returns:
實際輸出檔案的路徑
"""
# 建立 Plotly Figure
fig = go.Figure(data=plotly_data.get('data', []), layout=plotly_layout)
# 處理輸出路徑
output_path = Path(output_path)
if output_path.is_dir():
# 如果是目錄,生成預設檔名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{filename_prefix}_{timestamp}.{options.fmt.value}"
full_path = output_path / filename
else:
full_path = output_path
# 匯出
return self.export_engine.export(fig, full_path, options)
def generate_default_filename(self, fmt: ExportFormat) -> str:
"""
生成預設檔名
Args:
fmt: 檔案格式
Returns:
預設檔名
"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
return f"timeline_{timestamp}.{fmt.value}"
def create_metadata(title: str = "TimeLine Designer") -> dict:
"""
建立 PDF 元資料
Args:
title: 文件標題
Returns:
元資料字典
"""
return {
'Title': title,
'Creator': 'TimeLine Designer v1.0',
'Producer': 'Plotly + Kaleido',
'CreationDate': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
}
# 匯出主要介面
__all__ = [
'ExportEngine',
'TimelineExporter',
'ExportError',
'ExportOptions',
'ExportFormat',
]

517
backend/importer.py Normal file
View File

@@ -0,0 +1,517 @@
"""
CSV/XLSX 匯入模組
本模組負責處理時間軸事件的資料匯入。
支援 CSV 和 XLSX 格式,包含欄位自動對應與格式容錯功能。
Author: AI Agent
Version: 1.0.0
DocID: SDD-IMP-001
Related: TDD-UT-IMP-001
Rationale: 實現 SDD.md 定義的 POST /import API 功能
"""
import csv
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Any, Optional, Union
import logging
try:
import pandas as pd
PANDAS_AVAILABLE = True
except ImportError:
PANDAS_AVAILABLE = False
from .schemas import Event, ImportResult, EventType
# 設定日誌
logger = logging.getLogger(__name__)
class ImporterError(Exception):
"""匯入器錯誤基礎類別"""
pass
class FieldMapper:
"""
欄位對應器
負責將不同的欄位名稱映射到標準欄位。
支援多語言和不同命名習慣。
"""
# 欄位對應字典
FIELD_MAPPING = {
'id': ['id', 'ID', '編號', '序號', 'identifier'],
'title': ['title', 'Title', '標題', '名稱', 'name', 'event'],
'start': ['start', 'Start', '開始', '開始時間', 'start_time', 'begin', 'time', 'Time', '時間', 'date', 'Date', '日期'],
'group': ['group', 'Group', '群組', '分類', 'category', 'phase'],
'description': ['description', 'Description', '描述', '說明', 'detail', 'note'],
'color': ['color', 'Color', '顏色', 'colour'],
}
@classmethod
def map_fields(cls, headers: List[str]) -> Dict[str, str]:
"""
將 CSV/XLSX 的欄位名稱映射到標準欄位
Args:
headers: 原始欄位名稱列表
Returns:
映射字典 {標準欄位: 原始欄位}
"""
mapping = {}
headers_lower = [h.strip() for h in headers]
for standard_field, variants in cls.FIELD_MAPPING.items():
for header in headers_lower:
if header in variants or header.lower() in [v.lower() for v in variants]:
# 找到原始 header保留大小寫
original_header = headers[headers_lower.index(header)]
mapping[standard_field] = original_header
break
return mapping
@classmethod
def validate_required_fields(cls, mapping: Dict[str, str]) -> List[str]:
"""
驗證必要欄位是否存在
Args:
mapping: 欄位映射字典
Returns:
缺少的必要欄位列表
"""
required_fields = ['id', 'title', 'start']
missing_fields = [f for f in required_fields if f not in mapping]
return missing_fields
class DateParser:
"""
日期解析器
支援多種日期格式的容錯解析。
"""
# 支援的日期格式列表
DATE_FORMATS = [
'%Y-%m-%d %H:%M:%S',
'%Y/%m/%d %H:%M:%S',
'%Y-%m-%d',
'%Y/%m/%d',
'%d-%m-%Y',
'%d/%m/%Y',
'%Y年%m月%d',
'%Y年%m月%d%H:%M:%S',
'%Y-%m-%dT%H:%M:%S',
'%Y-%m-%dT%H:%M:%S.%f',
]
@classmethod
def parse(cls, date_str: str) -> Optional[datetime]:
"""
解析日期字串
Args:
date_str: 日期字串或 Excel 日期序列號
Returns:
datetime 物件,解析失敗則回傳 None
"""
if not date_str or (isinstance(date_str, str) and not date_str.strip()):
return None
# 如果是數字Excel 日期序列號),先轉換
if isinstance(date_str, (int, float)):
if PANDAS_AVAILABLE:
try:
# Excel 日期從 1899-12-30 開始計算
return pd.to_datetime(date_str, origin='1899-12-30', unit='D')
except Exception as e:
logger.warning(f"無法解析 Excel 日期序列號 {date_str}: {str(e)}")
return None
else:
# 沒有 pandas使用標準庫手動計算
try:
excel_epoch = datetime(1899, 12, 30)
return excel_epoch + timedelta(days=int(date_str))
except Exception as e:
logger.warning(f"無法解析 Excel 日期序列號 {date_str}: {str(e)}")
return None
date_str = str(date_str).strip()
# 嘗試各種格式
for fmt in cls.DATE_FORMATS:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
# 嘗試使用 pandas 的智能解析(如果可用)
if PANDAS_AVAILABLE:
try:
return pd.to_datetime(date_str)
except Exception:
pass
logger.warning(f"無法解析日期: {date_str}")
return None
class ColorValidator:
"""
顏色格式驗證器
"""
# HEX 顏色正則表達式
HEX_PATTERN = re.compile(r'^#[0-9A-Fa-f]{6}$')
# 預設顏色
DEFAULT_COLORS = [
'#3B82F6', # 藍色
'#10B981', # 綠色
'#F59E0B', # 橙色
'#EF4444', # 紅色
'#8B5CF6', # 紫色
'#EC4899', # 粉色
'#14B8A6', # 青色
'#F97316', # 深橙
]
@classmethod
def validate(cls, color: str, index: int = 0) -> str:
"""
驗證顏色格式
Args:
color: 顏色字串
index: 索引(用於選擇預設顏色)
Returns:
有效的 HEX 顏色代碼
"""
# 確保 index 是整數(防止 pandas 傳入 float
index = int(index) if index is not None else 0
if not color:
return cls.DEFAULT_COLORS[index % len(cls.DEFAULT_COLORS)]
color = str(color).strip().upper()
# 補充 # 符號
if not color.startswith('#'):
color = '#' + color
# 驗證格式
if cls.HEX_PATTERN.match(color):
return color
# 格式無效,使用預設顏色
logger.warning(f"無效的顏色格式: {color},使用預設顏色")
return cls.DEFAULT_COLORS[index % len(cls.DEFAULT_COLORS)]
class CSVImporter:
"""
CSV/XLSX 匯入器
負責讀取 CSV 或 XLSX 檔案並轉換為 Event 物件列表。
"""
def __init__(self):
self.field_mapper = FieldMapper()
self.date_parser = DateParser()
self.color_validator = ColorValidator()
def import_file(self, file_path: Union[str, Path]) -> ImportResult:
"""
匯入 CSV 或 XLSX 檔案
Args:
file_path: 檔案路徑
Returns:
ImportResult 物件
"""
file_path = Path(file_path)
if not file_path.exists():
return ImportResult(
success=False,
errors=[f"檔案不存在: {file_path}"],
total_rows=0,
imported_count=0
)
# 根據副檔名選擇處理方式
if file_path.suffix.lower() == '.csv':
return self._import_csv(file_path)
elif file_path.suffix.lower() in ['.xlsx', '.xls']:
return self._import_xlsx(file_path)
else:
return ImportResult(
success=False,
errors=[f"不支援的檔案格式: {file_path.suffix}"],
total_rows=0,
imported_count=0
)
def _import_csv(self, file_path: Path) -> ImportResult:
"""
匯入 CSV 檔案
Args:
file_path: CSV 檔案路徑
Returns:
ImportResult 物件
"""
events = []
errors = []
try:
with open(file_path, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
headers = reader.fieldnames
if not headers:
return ImportResult(
success=False,
errors=["CSV 檔案為空"],
total_rows=0,
imported_count=0
)
# 欄位映射
field_mapping = self.field_mapper.map_fields(headers)
logger.info(f"CSV 欄位映射結果: {field_mapping}")
logger.info(f"原始欄位: {headers}")
missing_fields = self.field_mapper.validate_required_fields(field_mapping)
if missing_fields:
logger.error(f"缺少必要欄位: {missing_fields}")
return ImportResult(
success=False,
errors=[f"缺少必要欄位: {', '.join(missing_fields)}"],
total_rows=0,
imported_count=0
)
# 逐行處理
row_num = 1
for row in reader:
row_num += 1
try:
logger.debug(f"處理第 {row_num} 行: {row}")
event = self._parse_row(row, field_mapping, row_num)
if event:
events.append(event)
logger.debug(f"成功匯入第 {row_num}")
else:
logger.warning(f"{row_num} 行返回 None")
except Exception as e:
error_msg = f"{row_num} 行錯誤: {str(e)}"
errors.append(error_msg)
logger.error(error_msg)
return ImportResult(
success=True,
events=events,
errors=errors,
total_rows=int(row_num - 1),
imported_count=int(len(events))
)
except Exception as e:
logger.error(f"CSV 匯入失敗: {str(e)}")
return ImportResult(
success=False,
errors=[f"CSV 匯入失敗: {str(e)}"],
total_rows=0,
imported_count=0
)
def _import_xlsx(self, file_path: Path) -> ImportResult:
"""
匯入 XLSX 檔案
Args:
file_path: XLSX 檔案路徑
Returns:
ImportResult 物件
"""
if not PANDAS_AVAILABLE:
return ImportResult(
success=False,
errors=["需要安裝 pandas 和 openpyxl 以支援 XLSX 匯入"],
total_rows=0,
imported_count=0
)
try:
# 讀取第一個工作表
df = pd.read_excel(file_path, sheet_name=0)
if df.empty:
return ImportResult(
success=False,
errors=["XLSX 檔案為空"],
total_rows=0,
imported_count=0
)
# 轉換為字典列表
records = df.to_dict('records')
headers = df.columns.tolist()
# 欄位映射
field_mapping = self.field_mapper.map_fields(headers)
logger.info(f"XLSX 欄位映射結果: {field_mapping}")
logger.info(f"原始欄位: {headers}")
missing_fields = self.field_mapper.validate_required_fields(field_mapping)
if missing_fields:
logger.error(f"缺少必要欄位: {missing_fields}")
return ImportResult(
success=False,
errors=[f"缺少必要欄位: {', '.join(missing_fields)}"],
total_rows=0,
imported_count=0
)
# 逐行處理
events = []
errors = []
for idx, row in enumerate(records, start=2): # Excel 從第 2 行開始(第 1 行是標題)
try:
event = self._parse_row(row, field_mapping, idx)
if event:
events.append(event)
except Exception as e:
errors.append(f"{idx} 行錯誤: {str(e)}")
return ImportResult(
success=True,
events=events,
errors=errors,
total_rows=int(len(records)),
imported_count=int(len(events))
)
except Exception as e:
logger.error(f"XLSX 匯入失敗: {str(e)}")
return ImportResult(
success=False,
errors=[f"XLSX 匯入失敗: {str(e)}"],
total_rows=0,
imported_count=0
)
def _parse_row(self, row: Dict[str, Any], field_mapping: Dict[str, str], row_num: int) -> Optional[Event]:
"""
解析單行資料
Args:
row: 行資料字典
field_mapping: 欄位映射
row_num: 行號
Returns:
Event 物件或 None
"""
# 輔助函數:安全地轉換為字串(處理 NaN、None、float 等)
def safe_str(value):
if pd.isna(value) if PANDAS_AVAILABLE else (value is None or value == ''):
return ''
# 如果是 float 且接近整數,轉為整數後再轉字串
if isinstance(value, float):
if value == int(value):
return str(int(value))
return str(value).strip()
# 🔍 DEBUG: 顯示原始 row 和 field_mapping
logger.debug(f" Row keys: {list(row.keys())}")
logger.debug(f" Field mapping: {field_mapping}")
# 提取欄位值
event_id = safe_str(row.get(field_mapping['id'], ''))
title = safe_str(row.get(field_mapping['title'], ''))
start_str = safe_str(row.get(field_mapping['start'], '')) # 🔧 修復:也要使用 safe_str 轉換
group = safe_str(row.get(field_mapping.get('group', ''), '')) or None
description = safe_str(row.get(field_mapping.get('description', ''), '')) or None
color = safe_str(row.get(field_mapping.get('color', ''), ''))
# 🔍 DEBUG: 顯示提取的欄位值
logger.debug(f" 提取欄位 - ID: '{event_id}', 標題: '{title}', 時間: '{start_str}'")
# 驗證必要欄位
if not event_id or not title:
raise ValueError("缺少 ID 或標題")
if not start_str:
raise ValueError("缺少時間欄位")
# 解析時間(只有一個時間欄位)
start = self.date_parser.parse(start_str)
if not start:
raise ValueError(f"無效的時間: {start_str}")
# 🔧 修復:將 pandas Timestamp 轉換為標準 datetime
if PANDAS_AVAILABLE:
if isinstance(start, pd.Timestamp):
start = start.to_pydatetime()
# 驗證顏色(確保返回的是字串,不是 None
color = self.color_validator.validate(color, int(row_num))
if not color: # 防禦性檢查
color = self.color_validator.DEFAULT_COLORS[0]
# 所有事件都是時間點類型(不再有區間)
event_type = EventType.POINT
end = None # 不再使用 end 欄位
# 建立 Event 物件
try:
event = Event(
id=event_id,
title=title,
start=start,
end=end,
group=group,
description=description,
color=color,
event_type=event_type
)
# 調試:確認所有欄位類型
logger.debug(f"Event 創建成功: id={type(event.id).__name__}, title={type(event.title).__name__}, "
f"start={type(event.start).__name__}, end={type(event.end).__name__ if event.end else 'None'}, "
f"group={type(event.group).__name__ if event.group else 'None'}, "
f"description={type(event.description).__name__ if event.description else 'None'}, "
f"color={type(event.color).__name__}")
return event
except Exception as e:
logger.error(f"創建 Event 失敗: {str(e)}")
logger.error(f" id={event_id} ({type(event_id).__name__})")
logger.error(f" title={title} ({type(title).__name__})")
logger.error(f" start={start} ({type(start).__name__})")
logger.error(f" end={end} ({type(end).__name__ if end else 'None'})")
logger.error(f" group={group} ({type(group).__name__ if group else 'None'})")
logger.error(f" description={description} ({type(description).__name__ if description else 'None'})")
logger.error(f" color={color} ({type(color).__name__})")
raise
# 匯出主要介面
__all__ = ['CSVImporter', 'ImportResult', 'ImporterError']

465
backend/main.py Normal file
View File

@@ -0,0 +1,465 @@
"""
FastAPI 主程式
本模組提供時間軸設計工具的 REST API 服務。
遵循 SDD.md 定義的 API 規範。
Author: AI Agent
Version: 1.0.0
DocID: SDD-API-001
Rationale: 實現 SDD.md 第3節定義的 API 接口
"""
import os
import tempfile
from pathlib import Path
from typing import List, Optional
from datetime import datetime
import logging
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from .schemas import (
Event, TimelineConfig, ExportOptions, Theme,
ImportResult, RenderResult, APIResponse,
ThemeStyle, ExportFormat
)
from .importer import CSVImporter, ImporterError
from .renderer_timeline import ClassicTimelineRenderer
from .export import TimelineExporter, ExportError
# 設定日誌
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 建立 FastAPI 應用
app = FastAPI(
title="TimeLine Designer API",
description="時間軸設計工具 REST API",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc"
)
# 設定 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 在生產環境應該限制為特定來源
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 全域儲存(簡單起見,使用記憶體儲存,實際應用應使用資料庫)
events_store: List[Event] = []
# 初始化服務
csv_importer = CSVImporter()
timeline_renderer = ClassicTimelineRenderer()
timeline_exporter = TimelineExporter()
# ==================== 健康檢查 ====================
@app.get("/health", tags=["System"])
async def health_check():
"""健康檢查端點"""
return APIResponse(
success=True,
message="Service is healthy",
data={
"version": "1.0.0",
"timestamp": datetime.now().isoformat()
}
)
# ==================== 匯入 API ====================
@app.post("/api/import", response_model=ImportResult, tags=["Import"])
async def import_events(file: UploadFile = File(...)):
"""
匯入事件資料
對應 SDD.md - POST /import
支援 CSV 和 XLSX 格式
Args:
file: 上傳的檔案
Returns:
ImportResult: 匯入結果
"""
try:
# 驗證檔案類型
if not file.filename:
raise HTTPException(status_code=400, detail="未提供檔案名稱")
file_ext = Path(file.filename).suffix.lower()
if file_ext not in ['.csv', '.xlsx', '.xls']:
raise HTTPException(
status_code=400,
detail=f"不支援的檔案格式: {file_ext},僅支援 CSV 和 XLSX"
)
# 儲存上傳檔案到臨時目錄
with tempfile.NamedTemporaryFile(delete=False, suffix=file_ext) as tmp_file:
content = await file.read()
tmp_file.write(content)
tmp_path = tmp_file.name
try:
# 匯入資料
result = csv_importer.import_file(tmp_path)
if result.success:
# 更新全域儲存
global events_store
events_store = result.events
logger.info(f"成功匯入 {result.imported_count} 筆事件")
# 🔍 調試:檢查 result 的所有欄位類型
logger.debug(f"ImportResult 類型檢查:")
logger.debug(f" success: {type(result.success).__name__}")
logger.debug(f" total_rows: {type(result.total_rows).__name__} = {result.total_rows}")
logger.debug(f" imported_count: {type(result.imported_count).__name__} = {result.imported_count}")
logger.debug(f" events count: {len(result.events)}")
logger.debug(f" errors count: {len(result.errors)}")
return result
finally:
# 清理臨時檔案
os.unlink(tmp_path)
except HTTPException:
# Re-raise HTTP exceptions (from validation)
raise
except ImporterError as e:
logger.error(f"匯入失敗: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"未預期的錯誤: {str(e)}")
raise HTTPException(status_code=500, detail=f"伺服器錯誤: {str(e)}")
# ==================== 事件管理 API ====================
@app.get("/api/events", response_model=List[Event], tags=["Events"])
async def get_events():
"""
取得事件列表
對應 SDD.md - GET /events
Returns:
List[Event]: 事件列表
"""
return events_store
@app.get("/api/events/raw", tags=["Events"])
async def get_raw_events():
"""
取得原始事件資料(用於前端 D3.js 渲染)
返回不經過任何布局計算的原始事件資料,
供前端 D3 Force-Directed Layout 使用。
Returns:
dict: 包含原始事件資料的字典
"""
return {
"success": True,
"events": [
{
"id": i,
"start": event.start.isoformat(),
"end": event.end.isoformat() if event.end else None,
"title": event.title,
"description": event.description or "",
"color": event.color or "#3B82F6",
"layer": i % 4 # 簡單的層級分配0-3 循環
}
for i, event in enumerate(events_store)
],
"count": len(events_store)
}
@app.post("/api/events", response_model=Event, tags=["Events"])
async def add_event(event: Event):
"""
新增單一事件
Args:
event: 事件物件
Returns:
Event: 新增的事件
"""
global events_store
events_store.append(event)
logger.info(f"新增事件: {event.id} - {event.title}")
return event
@app.delete("/api/events/{event_id}", tags=["Events"])
async def delete_event(event_id: str):
"""
刪除事件
Args:
event_id: 事件ID
Returns:
APIResponse: 操作結果
"""
global events_store
original_count = len(events_store)
events_store = [e for e in events_store if e.id != event_id]
if len(events_store) < original_count:
logger.info(f"刪除事件: {event_id}")
return APIResponse(success=True, message=f"成功刪除事件 {event_id}")
else:
raise HTTPException(status_code=404, detail=f"找不到事件: {event_id}")
@app.delete("/api/events", tags=["Events"])
async def clear_events():
"""
清空所有事件
Returns:
APIResponse: 操作結果
"""
global events_store
count = len(events_store)
events_store = []
logger.info(f"清空事件,共 {count}")
return APIResponse(success=True, message=f"成功清空 {count} 筆事件")
# ==================== 渲染 API ====================
class RenderRequest(BaseModel):
"""渲染請求模型"""
events: Optional[List[Event]] = None
config: TimelineConfig = TimelineConfig()
@app.post("/api/render", response_model=RenderResult, tags=["Render"])
async def render_timeline(request: RenderRequest):
"""
生成時間軸 JSON
對應 SDD.md - POST /render
生成 Plotly JSON 格式的時間軸資料
Args:
request: 渲染請求(可選事件列表與配置)
Returns:
RenderResult: Plotly JSON 資料
"""
try:
# 使用請求中的事件或全域事件
events = request.events if request.events is not None else events_store
if not events:
logger.warning("嘗試渲染空白事件列表")
# 渲染
result = timeline_renderer.render(events, request.config)
if result.success:
logger.info(f"成功渲染 {len(events)} 筆事件")
else:
logger.error("渲染失敗")
return result
except Exception as e:
logger.error(f"渲染錯誤: {str(e)}")
raise HTTPException(status_code=500, detail=f"渲染失敗: {str(e)}")
# ==================== 匯出 API ====================
class ExportRequest(BaseModel):
"""匯出請求模型"""
plotly_data: dict
plotly_layout: dict
options: ExportOptions
filename: Optional[str] = None
@app.post("/api/export", tags=["Export"])
async def export_timeline(request: ExportRequest, background_tasks: BackgroundTasks):
"""
導出時間軸圖
對應 SDD.md - POST /export
匯出為 PNG、PDF 或 SVG 格式
Args:
request: 匯出請求
background_tasks: 背景任務(用於清理臨時檔案)
Returns:
FileResponse: 圖檔
"""
try:
# 建立臨時輸出目錄
temp_dir = Path(tempfile.gettempdir()) / "timeline_exports"
temp_dir.mkdir(exist_ok=True)
# 生成檔名
if request.filename:
filename = request.filename
else:
filename = timeline_exporter.generate_default_filename(request.options.fmt)
output_path = temp_dir / filename
# 匯出
result_path = timeline_exporter.export_from_plotly_json(
request.plotly_data,
request.plotly_layout,
output_path,
request.options
)
logger.info(f"成功匯出: {result_path}")
# 設定背景任務清理檔案1小時後
def cleanup_file():
try:
if result_path.exists():
os.unlink(result_path)
logger.info(f"清理臨時檔案: {result_path}")
except Exception as e:
logger.warning(f"清理檔案失敗: {str(e)}")
background_tasks.add_task(cleanup_file)
# 回傳檔案
media_type_map = {
ExportFormat.PNG: "image/png",
ExportFormat.PDF: "application/pdf",
ExportFormat.SVG: "image/svg+xml",
}
return FileResponse(
path=str(result_path),
media_type=media_type_map.get(request.options.fmt, "application/octet-stream"),
filename=result_path.name
)
except ExportError as e:
logger.error(f"匯出失敗: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"未預期的錯誤: {str(e)}")
raise HTTPException(status_code=500, detail=f"伺服器錯誤: {str(e)}")
# ==================== 主題 API ====================
@app.get("/api/themes", response_model=List[Theme], tags=["Themes"])
async def get_themes():
"""
取得主題列表
對應 SDD.md - GET /themes
Returns:
List[Theme]: 主題列表
"""
themes = [
Theme(
name="現代風格",
style=ThemeStyle.MODERN,
primary_color="#3B82F6",
background_color="#FFFFFF",
text_color="#1F2937"
),
Theme(
name="經典風格",
style=ThemeStyle.CLASSIC,
primary_color="#6366F1",
background_color="#F9FAFB",
text_color="#374151"
),
Theme(
name="極簡風格",
style=ThemeStyle.MINIMAL,
primary_color="#000000",
background_color="#FFFFFF",
text_color="#000000"
),
Theme(
name="企業風格",
style=ThemeStyle.CORPORATE,
primary_color="#1F2937",
background_color="#F3F4F6",
text_color="#111827"
),
]
return themes
# ==================== 錯誤處理 ====================
@app.exception_handler(404)
async def not_found_handler(request, exc):
"""404 錯誤處理"""
return JSONResponse(
status_code=404,
content=APIResponse(
success=False,
message="找不到請求的資源",
error_code="NOT_FOUND"
).dict()
)
@app.exception_handler(500)
async def internal_error_handler(request, exc):
"""500 錯誤處理"""
logger.error(f"內部伺服器錯誤: {str(exc)}")
return JSONResponse(
status_code=500,
content=APIResponse(
success=False,
message="內部伺服器錯誤",
error_code="INTERNAL_ERROR"
).dict()
)
# ==================== 啟動事件 ====================
@app.on_event("startup")
async def startup_event():
"""應用啟動時執行"""
logger.info("TimeLine Designer API 啟動")
logger.info("API 文檔: http://localhost:8000/api/docs")
@app.on_event("shutdown")
async def shutdown_event():
"""應用關閉時執行"""
logger.info("TimeLine Designer API 關閉")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")

455
backend/path_planner.py Normal file
View File

@@ -0,0 +1,455 @@
"""
網格化路徑規劃器
使用BFS算法在網格化的繪圖區域中為連接線尋找最佳路徑
完全避開標籤障礙物。
Author: AI Agent
Version: 1.0.0
"""
import logging
from typing import List, Tuple, Optional, Dict
from datetime import datetime, timedelta
from collections import deque
import numpy as np
logger = logging.getLogger(__name__)
class GridMap:
"""
2D網格地圖
用於路徑規劃的網格化表示,支持障礙物標記和路徑搜尋。
"""
# 格點狀態常量
FREE = 0
OBSTACLE = 1
PATH = 2
def __init__(
self,
time_range_seconds: float,
y_min: float,
y_max: float,
grid_cols: int,
grid_rows: int,
time_start: datetime
):
"""
初始化網格地圖
Args:
time_range_seconds: 時間範圍(秒)
y_min: Y軸最小值
y_max: Y軸最大值
grid_cols: 網格列數X方向
grid_rows: 網格行數Y方向
time_start: 時間軸起始時間
"""
self.time_range_seconds = time_range_seconds
self.y_min = y_min
self.y_max = y_max
self.grid_cols = grid_cols
self.grid_rows = grid_rows
self.time_start = time_start
# 創建網格初始全為FREE
self.grid = np.zeros((grid_rows, grid_cols), dtype=np.int8)
# 座標轉換比例
self.seconds_per_col = time_range_seconds / grid_cols
self.y_per_row = (y_max - y_min) / grid_rows
logger.info(f"創建網格地圖: {grid_cols}× {grid_rows}")
logger.info(f" 時間範圍: {time_range_seconds:.0f}秒 ({time_range_seconds/86400:.1f}天)")
logger.info(f" Y軸範圍: {y_min:.1f} ~ {y_max:.1f}")
logger.info(f" 解析度: {self.seconds_per_col:.2f}秒/格, {self.y_per_row:.3f}Y/格")
def datetime_to_grid_x(self, dt: datetime) -> int:
"""將datetime轉換為網格X座標"""
seconds = (dt - self.time_start).total_seconds()
col = int(seconds / self.seconds_per_col)
return max(0, min(col, self.grid_cols - 1))
def seconds_to_grid_x(self, seconds: float) -> int:
"""將秒數轉換為網格X座標"""
col = int(seconds / self.seconds_per_col)
return max(0, min(col, self.grid_cols - 1))
def y_to_grid_y(self, y: float) -> int:
"""將Y座標轉換為網格Y座標注意Y軸向上但行索引向下"""
# Y軸向上為正但網格行索引向下增加需要翻轉
normalized_y = (y - self.y_min) / (self.y_max - self.y_min)
row = int((1 - normalized_y) * self.grid_rows)
return max(0, min(row, self.grid_rows - 1))
def grid_to_datetime(self, col: int) -> datetime:
"""將網格X座標轉換為datetime"""
seconds = col * self.seconds_per_col
return self.time_start + timedelta(seconds=seconds)
def grid_to_y(self, row: int) -> float:
"""將網格Y座標轉換為Y座標"""
normalized_y = 1 - (row / self.grid_rows)
return self.y_min + normalized_y * (self.y_max - self.y_min)
def mark_rectangle(
self,
center_x_datetime: datetime,
center_y: float,
width_seconds: float,
height: float,
state: int = OBSTACLE,
expansion_ratio: float = 0.1
):
"""
標記矩形區域
Args:
center_x_datetime: 矩形中心X座標datetime
center_y: 矩形中心Y座標
width_seconds: 矩形寬度(秒)
height: 矩形高度
state: 標記狀態OBSTACLE或PATH
expansion_ratio: 外擴比例默認10%
"""
# 外擴
expanded_width = width_seconds * (1 + expansion_ratio)
expanded_height = height * (1 + expansion_ratio)
# 計算矩形範圍
center_x_seconds = (center_x_datetime - self.time_start).total_seconds()
x_min = center_x_seconds - expanded_width / 2
x_max = center_x_seconds + expanded_width / 2
y_min = center_y - expanded_height / 2
y_max = center_y + expanded_height / 2
# 轉換為網格座標
col_min = self.seconds_to_grid_x(x_min)
col_max = self.seconds_to_grid_x(x_max)
row_min = self.y_to_grid_y(y_max) # 注意Y軸翻轉
row_max = self.y_to_grid_y(y_min)
# 標記網格
for row in range(row_min, row_max + 1):
for col in range(col_min, col_max + 1):
if 0 <= row < self.grid_rows and 0 <= col < self.grid_cols:
self.grid[row, col] = state
def mark_path(
self,
path_points: List[Tuple[datetime, float]],
width_expansion: float = 2.5
):
"""
標記路徑為障礙物
Args:
path_points: 路徑點列表 [(datetime, y), ...]
width_expansion: 寬度擴展倍數
策略:
1. 標記所有線段(包括起點線段)
2. 但是起點線段只標記離開時間軸的垂直部分
3. 時間軸 y=0 本身不標記,避免阻擋其他起點
"""
if len(path_points) < 2:
return
# 標記所有線段
for i in range(len(path_points) - 1):
dt1, y1 = path_points[i]
dt2, y2 = path_points[i + 1]
# 如果是從時間軸y=0出發的第一段線段
if i == 0 and abs(y1) < 0.1:
# 只標記離開時間軸的部分(從 y=0.2 開始)
# 避免阻擋其他事件的起點
if abs(y2) > 0.2: # 確保終點不在時間軸上
# 使用線性插值找到 y=0.2 的點
if abs(y2 - y1) > 0.01:
t = (0.2 - y1) / (y2 - y1) if y2 > y1 else (-0.2 - y1) / (y2 - y1)
if 0 < t < 1:
# 計算 y=0.2 時的 datetime
seconds_offset = (dt2 - dt1).total_seconds() * t
dt_cutoff = dt1 + timedelta(seconds=seconds_offset)
y_cutoff = 0.2 if y2 > 0 else -0.2
# 只標記從 cutoff 點到終點的部分
col1 = self.datetime_to_grid_x(dt_cutoff)
row1 = self.y_to_grid_y(y_cutoff)
col2 = self.datetime_to_grid_x(dt2)
row2 = self.y_to_grid_y(y2)
self._mark_line(row1, col1, row2, col2, int(width_expansion))
else:
# t 不在範圍內,標記整段
col1 = self.datetime_to_grid_x(dt1)
row1 = self.y_to_grid_y(y1)
col2 = self.datetime_to_grid_x(dt2)
row2 = self.y_to_grid_y(y2)
self._mark_line(row1, col1, row2, col2, int(width_expansion))
# 如果終點也在時間軸上,不標記
else:
# 非起點線段,全部標記
col1 = self.datetime_to_grid_x(dt1)
row1 = self.y_to_grid_y(y1)
col2 = self.datetime_to_grid_x(dt2)
row2 = self.y_to_grid_y(y2)
self._mark_line(row1, col1, row2, col2, int(width_expansion))
def _mark_line(self, row1: int, col1: int, row2: int, col2: int, thickness: int = 1):
"""使用Bresenham算法標記線段"""
d_col = abs(col2 - col1)
d_row = abs(row2 - row1)
col_step = 1 if col1 < col2 else -1
row_step = 1 if row1 < row2 else -1
if d_col > d_row:
error = d_col / 2
row = row1
for col in range(col1, col2 + col_step, col_step):
self._mark_point_with_thickness(row, col, thickness)
error -= d_row
if error < 0:
row += row_step
error += d_col
else:
error = d_row / 2
col = col1
for row in range(row1, row2 + row_step, row_step):
self._mark_point_with_thickness(row, col, thickness)
error -= d_col
if error < 0:
col += col_step
error += d_row
def _mark_point_with_thickness(self, row: int, col: int, thickness: int):
"""標記點及其周圍(模擬線寬)"""
for dr in range(-thickness, thickness + 1):
for dc in range(-thickness, thickness + 1):
r = row + dr
c = col + dc
if 0 <= r < self.grid_rows and 0 <= c < self.grid_cols:
self.grid[r, c] = self.PATH
def is_free(self, row: int, col: int) -> bool:
"""檢查格點是否可通行"""
if not (0 <= row < self.grid_rows and 0 <= col < self.grid_cols):
return False
return self.grid[row, col] == self.FREE
def auto_calculate_grid_resolution(
num_events: int,
time_range_seconds: float,
canvas_width: int = 1200,
canvas_height: int = 600,
label_width_ratio: float = 0.15
) -> Tuple[int, int]:
"""
自動計算最佳網格解析度
綜合考慮:
1. 畫布大小目標每格12像素
2. 事件密度(密集時提高解析度)
3. 標籤大小每個標籤至少10格
Args:
num_events: 事件數量
time_range_seconds: 時間範圍(秒)
canvas_width: 畫布寬度(像素)
canvas_height: 畫布高度(像素)
label_width_ratio: 標籤寬度佔時間軸的比例
Returns:
(grid_cols, grid_rows): 網格列數和行數
"""
# 策略1基於畫布大小進一步提高密度每格3像素
pixels_per_cell = 3 # 每格3像素 = 非常精細的網格
cols_by_canvas = canvas_width // pixels_per_cell
rows_by_canvas = canvas_height // pixels_per_cell
# 策略2基於事件密度提高倍數
density = num_events / time_range_seconds if time_range_seconds > 0 else 0
if density > 0.001: # 高密度(<1000秒/事件)
density_multiplier = 2.5 # 提高倍數
elif density > 0.0001: # 中密度
density_multiplier = 2.0 # 提高倍數
else: # 低密度
density_multiplier = 1.5 # 提高倍數
cols_by_density = int(cols_by_canvas * density_multiplier)
rows_by_density = int(rows_by_canvas * density_multiplier)
# 策略3基於標籤大小每個標籤至少40格大幅提高精度
label_width_seconds = time_range_seconds * label_width_ratio
min_grids_per_label = 40 # 每標籤至少40格確保精確判斷
cols_by_label = int((time_range_seconds / label_width_seconds) * min_grids_per_label)
# 取最大值(最細網格),大幅提高上限
grid_cols = min(max(cols_by_canvas, cols_by_density, cols_by_label), 800) # 上限提高到800
grid_rows = min(max(rows_by_canvas, rows_by_density, 100), 400) # 上限提高到400
logger.info(f"自動計算網格解析度:")
logger.info(f" 基於畫布: {cols_by_canvas} × {rows_by_canvas}")
logger.info(f" 基於密度: {cols_by_density} × {rows_by_density} (倍數: {density_multiplier:.1f})")
logger.info(f" 基於標籤: {cols_by_label} × 30")
logger.info(f" 最終選擇: {grid_cols} × {grid_rows}")
return (grid_cols, grid_rows)
def find_path_bfs(
start_row: int,
start_col: int,
end_row: int,
end_col: int,
grid_map: GridMap,
direction_constraint: str = "up" # "up" or "down"
) -> Optional[List[Tuple[int, int]]]:
"""
使用BFS尋找路徑改進版優先離開時間軸
策略:
1. 優先垂直移動(離開時間軸)
2. 遇到障礙物才水平繞行
3. 使用優先隊列,根據與時間軸的距離排序
Args:
start_row, start_col: 起點網格座標
end_row, end_col: 終點網格座標
grid_map: 網格地圖
direction_constraint: 方向約束("up"往上,"down"往下)
Returns:
路徑點列表 [(row, col), ...] 或 None找不到路徑
"""
# 檢查起點和終點是否可通行
if not grid_map.is_free(start_row, start_col):
logger.warning(f"起點 ({start_row},{start_col}) 被障礙物佔據")
return None
if not grid_map.is_free(end_row, end_col):
logger.warning(f"終點 ({end_row},{end_col}) 被障礙物佔據")
return None
import heapq
# 計算時間軸的Y座標row
timeline_row = grid_map.y_to_grid_y(0)
# 優先隊列:(優先度, row, col, path)
# 優先度 = 與時間軸的距離(越遠越好)+ 路徑長度(越短越好)
start_priority = 0
heap = [(start_priority, start_row, start_col, [(start_row, start_col)])]
visited = set()
visited.add((start_row, start_col))
# 方向優先順序(垂直優先於水平)
if direction_constraint == "up":
# 優先往上,然後才左右
directions = [(-1, 0), (0, 1), (0, -1)] # 上、右、左
else: # "down"
# 優先往下,然後才左右
directions = [(1, 0), (0, 1), (0, -1)] # 下、右、左
max_iterations = grid_map.grid_rows * grid_map.grid_cols * 2
iterations = 0
while heap and iterations < max_iterations:
iterations += 1
_, current_row, current_col, path = heapq.heappop(heap)
# 到達終點
if current_row == end_row and current_col == end_col:
logger.info(f"找到路徑,長度: {len(path)},迭代: {iterations}")
return path
# 探索鄰居(按優先順序)
for d_row, d_col in directions:
next_row = current_row + d_row
next_col = current_col + d_col
# 檢查是否可通行
if (next_row, next_col) in visited:
continue
if not grid_map.is_free(next_row, next_col):
continue
# 計算優先度
# 1. 與時間軸的距離(主要因素)
distance_from_timeline = abs(next_row - timeline_row)
# 2. 曼哈頓距離到終點(次要因素)
manhattan_to_goal = abs(next_row - end_row) + abs(next_col - end_col)
# 3. 路徑長度(避免繞太遠)
path_length = len(path)
# 綜合優先度:離時間軸越遠越好,離目標越近越好
# 權重調整:優先離開時間軸
priority = (
-distance_from_timeline * 100 + # 負數因為要最大化
manhattan_to_goal * 10 +
path_length
)
# 添加到優先隊列
visited.add((next_row, next_col))
new_path = path + [(next_row, next_col)]
heapq.heappush(heap, (priority, next_row, next_col, new_path))
logger.warning(f"BFS未找到路徑 ({start_row},{start_col}) → ({end_row},{end_col})")
return None
def simplify_path(
path_grid: List[Tuple[int, int]],
grid_map: GridMap
) -> List[Tuple[datetime, float]]:
"""
簡化路徑並轉換為實際座標
合併連續同向的線段,移除不必要的轉折點。
Args:
path_grid: 網格路徑點 [(row, col), ...]
grid_map: 網格地圖
Returns:
簡化後的路徑 [(datetime, y), ...]
"""
if not path_grid:
return []
simplified = [path_grid[0]] # 起點
for i in range(1, len(path_grid) - 1):
prev_point = path_grid[i - 1]
curr_point = path_grid[i]
next_point = path_grid[i + 1]
# 計算方向
dir1 = (curr_point[0] - prev_point[0], curr_point[1] - prev_point[1])
dir2 = (next_point[0] - curr_point[0], next_point[1] - curr_point[1])
# 如果方向改變,保留這個轉折點
if dir1 != dir2:
simplified.append(curr_point)
simplified.append(path_grid[-1]) # 終點
# 轉換為實際座標
result = []
for row, col in simplified:
dt = grid_map.grid_to_datetime(col)
y = grid_map.grid_to_y(row)
result.append((dt, y))
logger.debug(f"路徑簡化: {len(path_grid)}{len(simplified)}")
return result

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']

1632
backend/renderer_timeline.py Normal file

File diff suppressed because it is too large Load Diff

257
backend/schemas.py Normal file
View File

@@ -0,0 +1,257 @@
"""
資料模型定義 (Data Schemas)
本模組定義 TimeLine Designer 所有資料結構。
遵循 Pydantic BaseModel 進行嚴格型別驗證。
Author: AI Agent
Version: 1.0.0
DocID: SDD-SCHEMA-001
Rationale: 實現 SDD.md 第2節定義的資料模型
"""
from datetime import datetime
from typing import Optional, Literal, List
from pydantic import BaseModel, Field, field_validator
from enum import Enum
class EventType(str, Enum):
"""事件類型枚舉"""
POINT = "point" # 時間點事件
RANGE = "range" # 時間區間事件
MILESTONE = "milestone" # 里程碑
class Event(BaseModel):
"""
時間軸事件模型
對應 SDD.md - 2. 資料模型 - Event
用於表示時間軸上的單一事件或時間區間。
"""
id: str = Field(..., description="事件唯一識別碼")
title: str = Field(..., min_length=1, max_length=200, description="事件標題")
start: datetime = Field(..., description="開始時間")
end: Optional[datetime] = Field(None, description="結束時間(可選)")
group: Optional[str] = Field(None, description="事件群組/分類")
description: Optional[str] = Field(None, max_length=1000, description="事件詳細描述")
color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$', description="事件顏色HEX格式")
event_type: EventType = Field(EventType.POINT, description="事件類型")
@field_validator('end')
@classmethod
def validate_end_after_start(cls, end, info):
"""驗證結束時間必須晚於開始時間"""
if end and info.data.get('start') and end < info.data['start']:
raise ValueError('結束時間必須晚於開始時間')
return end
class Config:
json_schema_extra = {
"example": {
"id": "evt-001",
"title": "專案啟動",
"start": "2024-01-01T09:00:00",
"end": "2024-01-01T17:00:00",
"group": "Phase 1",
"description": "專案正式啟動會議",
"color": "#3B82F6",
"event_type": "range"
}
}
class ThemeStyle(str, Enum):
"""主題樣式枚舉"""
MODERN = "modern"
CLASSIC = "classic"
MINIMAL = "minimal"
CORPORATE = "corporate"
class TimelineConfig(BaseModel):
"""
時間軸配置模型
對應 SDD.md - 2. 資料模型 - TimelineConfig
控制時間軸的顯示方式與視覺樣式。
"""
direction: Literal['horizontal', 'vertical'] = Field(
'horizontal',
description="時間軸方向"
)
theme: ThemeStyle = Field(
ThemeStyle.MODERN,
description="視覺主題"
)
show_grid: bool = Field(
True,
description="是否顯示網格線"
)
show_tooltip: bool = Field(
True,
description="是否顯示提示訊息"
)
enable_zoom: bool = Field(
True,
description="是否啟用縮放功能"
)
enable_drag: bool = Field(
True,
description="是否啟用拖曳功能"
)
class Config:
json_schema_extra = {
"example": {
"direction": "horizontal",
"theme": "modern",
"show_grid": True,
"show_tooltip": True,
"enable_zoom": True,
"enable_drag": True
}
}
class ExportFormat(str, Enum):
"""匯出格式枚舉"""
PNG = "png"
PDF = "pdf"
SVG = "svg"
class ExportOptions(BaseModel):
"""
匯出選項模型
對應 SDD.md - 2. 資料模型 - ExportOptions
控制時間軸圖檔的匯出格式與品質。
"""
fmt: ExportFormat = Field(..., description="匯出格式")
dpi: int = Field(
300,
ge=72,
le=600,
description="解析度DPI"
)
width: Optional[int] = Field(
1920,
ge=800,
le=4096,
description="圖片寬度(像素)"
)
height: Optional[int] = Field(
1080,
ge=600,
le=4096,
description="圖片高度(像素)"
)
transparent_background: bool = Field(
False,
description="是否使用透明背景"
)
class Config:
json_schema_extra = {
"example": {
"fmt": "pdf",
"dpi": 300,
"width": 1920,
"height": 1080,
"transparent_background": False
}
}
class Theme(BaseModel):
"""
主題定義模型
用於 /themes API 回傳主題列表。
"""
name: str = Field(..., description="主題名稱")
style: ThemeStyle = Field(..., description="主題樣式識別碼")
primary_color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$', description="主要顏色")
background_color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$', description="背景顏色")
text_color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$', description="文字顏色")
class Config:
json_schema_extra = {
"example": {
"name": "現代風格",
"style": "modern",
"primary_color": "#3B82F6",
"background_color": "#FFFFFF",
"text_color": "#1F2937"
}
}
class ImportResult(BaseModel):
"""
匯入結果模型
用於 /import API 回傳匯入結果。
"""
success: bool = Field(..., description="是否成功")
events: List[Event] = Field(default_factory=list, description="成功匯入的事件列表")
errors: List[str] = Field(default_factory=list, description="錯誤訊息列表")
total_rows: int = Field(0, description="總行數")
imported_count: int = Field(0, description="成功匯入數量")
class Config:
json_schema_extra = {
"example": {
"success": True,
"events": [],
"errors": [],
"total_rows": 100,
"imported_count": 98
}
}
class RenderResult(BaseModel):
"""
渲染結果模型
用於 /render API 回傳 Plotly JSON 格式的時間軸資料。
"""
success: bool = Field(..., description="是否成功")
data: dict = Field(..., description="Plotly 圖表資料JSON格式")
layout: dict = Field(..., description="Plotly 佈局設定")
config: dict = Field(default_factory=dict, description="Plotly 配置")
class Config:
json_schema_extra = {
"example": {
"success": True,
"data": {},
"layout": {},
"config": {}
}
}
class APIResponse(BaseModel):
"""
通用 API 回應模型
用於標準化 API 回應格式,提供一致的錯誤處理。
"""
success: bool = Field(..., description="操作是否成功")
message: str = Field("", description="回應訊息")
data: Optional[dict] = Field(None, description="回應資料")
error_code: Optional[str] = Field(None, description="錯誤代碼(如有)")
class Config:
json_schema_extra = {
"example": {
"success": True,
"message": "操作成功",
"data": None,
"error_code": None
}
}