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

View File

@@ -0,0 +1,2 @@
# 開發環境配置
VITE_API_BASE_URL=http://localhost:12010/api

24
frontend-react/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend-react/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend-react/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TimeLine Designer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8031
frontend-react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
{
"name": "frontend-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@types/d3": "^7.4.3",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"d3": "^7.9.0",
"lucide-react": "^0.552.0",
"plotly.js": "^3.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-dropzone": "^14.3.8",
"react-plotly.js": "^2.6.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.6.0",
"@types/plotly.js": "^3.0.8",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@types/react-plotly.js": "^2.6.3",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.21",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

287
frontend-react/src/App.tsx Normal file
View File

@@ -0,0 +1,287 @@
import { useState, useCallback } from 'react';
import Plot from 'react-plotly.js';
import { Upload, Download, Trash2, Sparkles } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { timelineAPI } from './api/timeline';
import type { TimelineConfig, ExportOptions } from './types';
function App() {
const [eventsCount, setEventsCount] = useState(0);
const [plotlyData, setPlotlyData] = useState<any>(null);
const [plotlyLayout, setPlotlyLayout] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'info' } | null>(null);
// Export options
const [exportFormat, setExportFormat] = useState<'pdf' | 'png' | 'svg'>('png');
const [exportDPI, setExportDPI] = useState(300);
// Show message helper
const showMessage = (text: string, type: 'success' | 'error' | 'info' = 'info') => {
setMessage({ text, type });
setTimeout(() => setMessage(null), 5000);
};
// Fetch events count
const updateEventsCount = async () => {
try {
const events = await timelineAPI.getEvents();
setEventsCount(events.length);
} catch (error: any) {
console.error('Failed to fetch events:', error);
}
};
// File drop handler
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (!file) return;
setLoading(true);
showMessage('上傳中...', 'info');
try {
const result = await timelineAPI.importFile(file);
if (result.success) {
showMessage(`✅ 成功匯入 ${result.imported_count} 筆事件!`, 'success');
await updateEventsCount();
} else {
showMessage(`❌ 匯入失敗: ${result.errors.join(', ')}`, 'error');
}
} catch (error: any) {
showMessage(`❌ 錯誤: ${error.message}`, 'error');
} finally {
setLoading(false);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'text/csv': ['.csv'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
},
multiple: false,
});
// Render timeline
const renderTimeline = async () => {
setLoading(true);
showMessage('渲染中...', 'info');
try {
const config: TimelineConfig = {
direction: 'horizontal',
theme: 'modern',
show_grid: true,
show_tooltip: true,
enable_zoom: true,
enable_drag: true,
};
const result = await timelineAPI.renderTimeline(config);
if (result.success) {
setPlotlyData(result.data);
setPlotlyLayout(result.layout);
showMessage('✅ 時間軸已生成!', 'success');
} else {
showMessage('❌ 渲染失敗', 'error');
}
} catch (error: any) {
showMessage(`❌ 錯誤: ${error.message}`, 'error');
} finally {
setLoading(false);
}
};
// Export timeline
const exportTimeline = async () => {
if (!plotlyData || !plotlyLayout) {
alert('請先生成時間軸預覽!');
return;
}
try {
const options: ExportOptions = {
fmt: exportFormat,
dpi: exportDPI,
width: 1920,
height: 1080,
transparent_background: false,
};
const blob = await timelineAPI.exportTimeline(plotlyData, plotlyLayout, options);
// Download file
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `timeline.${exportFormat}`;
a.click();
window.URL.revokeObjectURL(url);
showMessage('✅ 匯出成功!', 'success');
} catch (error: any) {
showMessage(`❌ 匯出失敗: ${error.message}`, 'error');
}
};
// Clear events
const clearEvents = async () => {
if (!confirm('確定要清空所有事件嗎?')) return;
try {
await timelineAPI.clearEvents();
await updateEventsCount();
setPlotlyData(null);
setPlotlyLayout(null);
showMessage('✅ 已清空所有事件', 'success');
} catch (error: any) {
showMessage(`❌ 錯誤: ${error.message}`, 'error');
}
};
// Initial load
useState(() => {
updateEventsCount();
});
return (
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-secondary-500 p-6">
<div className="container mx-auto max-w-7xl">
{/* Header */}
<header className="text-center mb-8 text-white">
<h1 className="text-5xl font-bold mb-2">📊 TimeLine Designer</h1>
</header>
{/* Message Alert */}
{message && (
<div className={`mb-6 p-4 rounded-lg ${
message.type === 'success' ? 'bg-green-100 text-green-800 border border-green-300' :
message.type === 'error' ? 'bg-red-100 text-red-800 border border-red-300' :
'bg-blue-100 text-blue-800 border border-blue-300'
}`}>
{message.text}
</div>
)}
{/* Main Content */}
<div className="space-y-6">
{/* 1. File Upload Section */}
<div className="card">
<h2 className="section-title">1. </h2>
<div
{...getRootProps()}
className={`border-3 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all ${
isDragActive
? 'border-secondary-500 bg-secondary-50'
: 'border-primary-300 hover:bg-primary-50 hover:border-primary-500'
}`}
>
<input {...getInputProps()} />
<Upload className="w-16 h-16 mx-auto mb-4 text-primary-500" />
<p className="text-lg font-medium text-gray-700">
{isDragActive ? '放開檔案以上傳' : '點擊或拖曳 CSV/XLSX 檔案至此處'}
</p>
<p className="text-sm text-gray-500 mt-2">支援格式: .csv, .xlsx, .xls</p>
</div>
</div>
{/* 2. Events Info */}
<div className="card">
<h2 className="section-title">2. </h2>
<p className="text-lg mb-4">
: <span className="inline-block bg-green-500 text-white px-4 py-1 rounded-full font-bold">{eventsCount}</span>
</p>
<div className="flex gap-3 flex-wrap">
<button
onClick={renderTimeline}
disabled={loading || eventsCount === 0}
className="btn-primary flex items-center gap-2"
>
<Sparkles size={20} />
</button>
<button
onClick={clearEvents}
disabled={eventsCount === 0}
className="btn-secondary flex items-center gap-2"
>
<Trash2 size={20} />
</button>
</div>
</div>
{/* 3. Timeline Preview */}
<div className="card">
<h2 className="section-title">3. </h2>
{loading && (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
)}
{/* Timeline 渲染 */}
{plotlyData && plotlyLayout && !loading && (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<Plot
data={plotlyData.data}
layout={plotlyLayout}
config={{ responsive: true }}
style={{ width: '100%', height: '600px' }}
/>
</div>
)}
{/* 空狀態 */}
{!loading && !plotlyData && (
<div className="text-center py-12 text-gray-400">
<p></p>
</div>
)}
</div>
{/* 4. Export Options */}
<div className="card">
<h2 className="section-title">4. </h2>
<div className="flex gap-3 flex-wrap items-center">
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as any)}
className="px-4 py-3 border-2 border-primary-300 rounded-lg font-medium cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="png">PNG </option>
<option value="pdf">PDF </option>
<option value="svg">SVG </option>
</select>
<select
value={exportDPI}
onChange={(e) => setExportDPI(Number(e.target.value))}
className="px-4 py-3 border-2 border-primary-300 rounded-lg font-medium cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value={150}>150 DPI ()</option>
<option value={300}>300 DPI ()</option>
<option value={600}>600 DPI ()</option>
</select>
<button
onClick={exportTimeline}
disabled={!plotlyData}
className="btn-primary flex items-center gap-2"
>
<Download size={20} />
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,36 @@
import axios from 'axios';
// API 基礎 URL - 可透過環境變數配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12010/api';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 30000, // 30 seconds
});
// Request interceptor
apiClient.interceptors.request.use(
(config) => {
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor
apiClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
console.error('[API Error]', error.response?.data || error.message);
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -0,0 +1,77 @@
import apiClient from './client';
import type { Event, ImportResult, RenderResult, TimelineConfig, ExportOptions, Theme } from '../types';
export const timelineAPI = {
// Health check
async healthCheck() {
const { data } = await apiClient.get('/health');
return data;
},
// Import CSV/XLSX
async importFile(file: File): Promise<ImportResult> {
const formData = new FormData();
formData.append('file', file);
const { data } = await apiClient.post('/import', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return data;
},
// Get all events
async getEvents(): Promise<Event[]> {
const { data } = await apiClient.get('/events');
return data;
},
// Get raw events (for D3.js rendering)
async getRawEvents(): Promise<any> {
const { data } = await apiClient.get('/events/raw');
return data;
},
// Add event
async addEvent(event: Omit<Event, 'id'>): Promise<Event> {
const { data } = await apiClient.post('/events', event);
return data;
},
// Delete event
async deleteEvent(id: string) {
const { data } = await apiClient.delete(`/events/${id}`);
return data;
},
// Clear all events
async clearEvents() {
const { data } = await apiClient.delete('/events');
return data;
},
// Render timeline
async renderTimeline(config?: TimelineConfig): Promise<RenderResult> {
const { data } = await apiClient.post('/render', { config });
return data;
},
// Export timeline
async exportTimeline(plotlyData: any, plotlyLayout: any, options: ExportOptions): Promise<Blob> {
const { data } = await apiClient.post('/export', {
plotly_data: plotlyData,
plotly_layout: plotlyLayout,
options,
}, {
responseType: 'blob',
});
return data;
},
// Get themes
async getThemes(): Promise<Theme[]> {
const { data } = await apiClient.get('/themes');
return data;
},
};

View File

@@ -0,0 +1,308 @@
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
interface Event {
id: number;
start: string;
end?: string;
title: string;
description: string;
color: string;
layer: number;
}
interface D3TimelineProps {
events: Event[];
width?: number;
height?: number;
}
interface Node extends d3.SimulationNodeDatum {
id: number;
type: 'event' | 'label';
eventId: number;
event: Event;
labelWidth: number;
labelHeight: number;
}
export default function D3Timeline({ events, width = 1200, height = 600 }: D3TimelineProps) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!svgRef.current || events.length === 0) return;
// 清空 SVG
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
// 邊距設定(增加左右邊距以防止截斷)
const margin = { top: 120, right: 120, bottom: 60, left: 120 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// 創建主 group
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// 時間範圍
const dates = events.map(e => new Date(e.start));
const xScale = d3.scaleTime()
.domain([d3.min(dates)!, d3.max(dates)!])
.range([0, innerWidth]);
// 時間軸線
const axisY = innerHeight / 2;
g.append('line')
.attr('x1', 0)
.attr('x2', innerWidth)
.attr('y1', axisY)
.attr('y2', axisY)
.attr('stroke', '#3B82F6')
.attr('stroke-width', 3);
// 準備節點資料
const nodes: Node[] = [];
events.forEach((event, i) => {
const eventX = xScale(new Date(event.start));
// 事件點節點(固定位置)
nodes.push({
id: i * 2,
type: 'event',
eventId: i,
x: eventX,
y: axisY,
fx: eventX, // 固定 X - 保證時間準確性
fy: axisY, // 固定 Y - 在時間軸上
event,
labelWidth: 0,
labelHeight: 0
});
// 標籤節點(可移動)
// 計算文字框尺寸(考慮標題、時間、描述)
const titleLength = event.title.length;
const hasDescription = event.description && event.description.length > 0;
const labelWidth = Math.max(titleLength * 9, 180); // 增加寬度
const labelHeight = hasDescription ? 90 : 70; // 有描述時增加高度
const initialY = event.layer % 2 === 0 ? axisY - 200 : axisY + 200; // 增加距離到 200
nodes.push({
id: i * 2 + 1,
type: 'label',
eventId: i,
x: eventX, // 初始 X 接近事件點
y: initialY, // 初始 Y 根據層級
fx: null,
fy: null,
event,
labelWidth,
labelHeight
});
});
// 連接線(標籤 → 事件點)
const links = nodes
.filter(n => n.type === 'label')
.map(label => ({
source: label.id,
target: label.id - 1 // 對應的事件點
}));
// 繪製可視化元素的函數
function updateVisualization() {
// 連接線
g.selectAll<SVGLineElement, any>('.link')
.data(links)
.join('line')
.attr('class', 'link')
.attr('x1', d => {
const source = nodes.find(n => n.id === (typeof d.source === 'number' ? d.source : (d.source as any).id))!;
return source.x!;
})
.attr('y1', d => {
const source = nodes.find(n => n.id === (typeof d.source === 'number' ? d.source : (d.source as any).id))!;
return source.y!;
})
.attr('x2', d => {
const target = nodes.find(n => n.id === (typeof d.target === 'number' ? d.target : (d.target as any).id))!;
return target.x!;
})
.attr('y2', d => {
const target = nodes.find(n => n.id === (typeof d.target === 'number' ? d.target : (d.target as any).id))!;
return target.y!;
})
.attr('stroke', '#94a3b8')
.attr('stroke-width', 1.5)
.attr('opacity', 0.7);
// 事件點
g.selectAll<SVGCircleElement, Node>('.event-node')
.data(nodes.filter(n => n.type === 'event'))
.join('circle')
.attr('class', 'event-node')
.attr('cx', d => d.x!)
.attr('cy', d => d.y!)
.attr('r', 8)
.attr('fill', d => d.event.color)
.attr('stroke', '#fff')
.attr('stroke-width', 2);
// 標籤文字框
const labelGroups = g.selectAll<SVGGElement, Node>('.label-group')
.data(nodes.filter(n => n.type === 'label'))
.join('g')
.attr('class', 'label-group')
.attr('transform', d => `translate(${d.x! - d.labelWidth / 2},${d.y! - d.labelHeight / 2})`);
// 文字框背景
labelGroups.selectAll('rect')
.data(d => [d])
.join('rect')
.attr('width', d => d.labelWidth)
.attr('height', d => d.labelHeight)
.attr('rx', 6)
.attr('fill', 'white')
.attr('opacity', 0.9)
.attr('stroke', d => d.event.color)
.attr('stroke-width', 2);
// 文字內容:標題
labelGroups.selectAll('.label-title')
.data(d => [d])
.join('text')
.attr('class', 'label-title')
.attr('x', d => d.labelWidth / 2)
.attr('y', 18)
.attr('text-anchor', 'middle')
.attr('font-size', 12)
.attr('font-weight', 'bold')
.attr('fill', '#1F2937')
.text(d => d.event.title);
// 文字內容:時間
labelGroups.selectAll('.label-time')
.data(d => [d])
.join('text')
.attr('class', 'label-time')
.attr('x', d => d.labelWidth / 2)
.attr('y', 35)
.attr('text-anchor', 'middle')
.attr('font-size', 9)
.attr('fill', '#6B7280')
.text(d => {
const date = new Date(d.event.start);
return date.toLocaleDateString('zh-TW') + ' ' + date.toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' });
});
// 文字內容:描述(如果有)
labelGroups.selectAll('.label-desc')
.data(d => d.event.description ? [d] : [])
.join('text')
.attr('class', 'label-desc')
.attr('x', d => d.labelWidth / 2)
.attr('y', 52)
.attr('text-anchor', 'middle')
.attr('font-size', 10)
.attr('fill', '#4B5563')
.text(d => {
const desc = d.event.description || '';
return desc.length > 25 ? desc.substring(0, 25) + '...' : desc;
});
}
// D3 力導向模擬
const simulation = d3.forceSimulation(nodes)
// 1. 碰撞力:標籤之間互相推開
.force('collide', d3.forceCollide<Node>()
.radius(d => {
if (d.type === 'label') {
// 使用橢圓碰撞半徑(考慮文字框寬高)
return Math.max(d.labelWidth / 2, d.labelHeight / 2) + 10;
}
return 5; // 事件點不參與碰撞
})
.strength(0.8)
)
// 2. 連結力:標籤拉向事件點(像彈簧)
.force('link', d3.forceLink(links)
.id(d => (d as Node).id)
.distance(100) // 理想距離
.strength(0.3) // 彈簧強度
)
// 3. X 方向定位力:標籤靠近事件點的 X 座標
.force('x', d3.forceX<Node>(d => {
if (d.type === 'label') {
const eventNode = nodes.find(n => n.type === 'event' && n.eventId === d.eventId);
return eventNode ? eventNode.x! : d.x!;
}
return d.x!;
}).strength(0.5))
// 4. Y 方向定位力:標籤保持在上方或下方(遠離時間軸)
.force('y', d3.forceY<Node>(d => {
if (d.type === 'label') {
// 增加到 ±180 讓標籤離時間軸更遠
return d.y! < axisY ? axisY - 180 : axisY + 180;
}
return axisY;
}).strength(0.4)) // 增加強度確保標籤保持距離
// 5. 每個 tick 更新位置和繪製
.on('tick', () => {
nodes.forEach(d => {
if (d.type === 'label') {
// 限制 Y 範圍
const minDistance = 100; // 最小距離時間軸 100px
if (d.y! < 20) d.y = 20;
if (d.y! > innerHeight - 20) d.y = innerHeight - 20;
// 確保標籤不會太靠近時間軸(避免重疊)
if (Math.abs(d.y! - axisY) < minDistance) {
d.y = d.y! < axisY ? axisY - minDistance : axisY + minDistance;
}
// 限制 X 範圍(考慮文字框寬度,防止超出邊界)
const eventNode = nodes.find(n => n.type === 'event' && n.eventId === d.eventId)!;
const maxOffset = 80;
const halfWidth = d.labelWidth / 2;
// 首先限制相對於事件點的偏移
if (Math.abs(d.x! - eventNode.x!) > maxOffset) {
d.x = eventNode.x! + (d.x! > eventNode.x! ? maxOffset : -maxOffset);
}
// 然後確保整個文字框在畫布範圍內
if (d.x! - halfWidth < 0) {
d.x = halfWidth;
}
if (d.x! + halfWidth > innerWidth) {
d.x = innerWidth - halfWidth;
}
}
});
updateVisualization();
});
// 初始繪製
updateVisualization();
// 清理函數
return () => {
simulation.stop();
};
}, [events, width, height]);
return (
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-lg">
<svg
ref={svgRef}
width={width}
height={height}
className="w-full h-auto"
/>
</div>
);
}

View File

@@ -0,0 +1,40 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply box-border;
}
body {
@apply m-0 min-h-screen;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei', sans-serif;
}
}
@layer components {
.container {
@apply max-w-7xl mx-auto px-4 py-8;
}
.card {
@apply bg-white rounded-xl shadow-2xl p-6;
}
.section-title {
@apply text-2xl font-bold text-primary-600 mb-4 pb-2 border-b-2 border-primary-600;
}
.btn {
@apply px-6 py-3 rounded-lg font-medium transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg active:translate-y-0 cursor-pointer;
}
.btn-primary {
@apply btn bg-gradient-to-r from-primary-500 to-secondary-500 text-white hover:from-primary-600 hover:to-secondary-600;
}
.btn-secondary {
@apply btn bg-gray-600 text-white hover:bg-gray-700;
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,63 @@
// ========== Event Types ==========
export interface Event {
id: string;
title: string;
start: string; // ISO date string
end?: string; // ISO date string
group?: string;
description?: string;
color?: string;
}
// ========== API Response Types ==========
export interface APIResponse<T = any> {
success: boolean;
message?: string;
data?: T;
error_code?: string;
}
export interface ImportResult {
success: boolean;
imported_count: number;
events: Event[];
errors: string[];
}
export interface RenderResult {
success: boolean;
data: any; // Plotly data object
layout: any; // Plotly layout object
config?: any; // Plotly config object
}
// ========== Config Types ==========
export interface TimelineConfig {
direction?: 'horizontal' | 'vertical';
theme?: 'modern' | 'classic' | 'dark';
show_grid?: boolean;
show_tooltip?: boolean;
enable_zoom?: boolean;
enable_drag?: boolean;
height?: number;
width?: number;
}
export interface ExportOptions {
fmt: 'pdf' | 'png' | 'svg';
dpi?: number;
width?: number;
height?: number;
transparent_background?: boolean;
}
// ========== Theme Types ==========
export interface Theme {
id: string;
name: string;
colors: {
background: string;
grid: string;
text: string;
};
}

View File

@@ -0,0 +1,38 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f4ff',
100: '#e0eafe',
200: '#c7d7fe',
300: '#a5b9fc',
400: '#8191f8',
500: '#667eea',
600: '#5b68e0',
700: '#4c52cd',
800: '#3e43a6',
900: '#363b83',
},
secondary: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#764ba2',
600: '#6b4391',
700: '#5a3778',
800: '#4a2d61',
900: '#3d2550',
},
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 12010,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})