Files
Timeline_Generator/MIGRATION_TO_D3_FORCE.md
beabigegg 2d37d23bcf v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 11:35:29 +08:00

14 KiB
Raw Blame History

遷移到 D3.js Force-Directed Layout - 實施計劃

📋 目標

將時間軸標籤避讓邏輯從後端 Plotly遷移到前端 D3.js d3-force,實現專業的標籤碰撞避讓。


🏗️ 架構變更

當前架構v7.0

┌─────────┐  事件資料  ┌─────────┐  Plotly圖表  ┌─────────┐
│ Python  │ --------> │  計算    │ ----------> │  React  │
│ 後端    │           │  標籤位置 │             │  前端   │
└─────────┘           └─────────┘             └─────────┘
                        ❌ 標籤避讓在這裡(效果差)

新架構D3 Force

┌─────────┐  事件資料  ┌─────────────┐  渲染座標  ┌─────────┐
│ Python  │ --------> │  D3 Force   │ ---------> │  React  │
│ 後端    │  (乾淨)   │  標籤避讓    │            │  前端   │
└─────────┘           └─────────────┘            └─────────┘
                        ✅ 力導向演算法在這裡

📦 步驟 1: 安裝 D3.js 依賴

cd frontend-react
npm install d3 d3-force d3-scale d3-axis d3-selection
npm install --save-dev @types/d3

安裝的模組

  • d3-force: 力導向布局核心
  • d3-scale: 時間軸刻度
  • d3-axis: 軸線繪製
  • d3-selection: DOM 操作

🔧 步驟 2: 修改後端 API

2.1 新增端點:返回原始事件資料

檔案: backend/main.py

@router.get("/api/events/raw")
async def get_raw_events():
    """
    返回原始事件資料(不做任何布局計算)
    供前端 D3.js 使用
    """
    events = event_manager.get_events()
    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,
                "color": event.color or "#3B82F6",
                "layer": event.layer
            }
            for i, event in enumerate(events)
        ],
        "count": len(events)
    }

2.2 保留 Plotly 端點作為備選

@router.post("/api/render")  # 保留舊版
@router.post("/api/render/plotly")  # 明確標記
async def render_plotly_timeline(config: TimelineConfig):
    # ... 現有代碼 ...

🎨 步驟 3: 創建 D3 時間軸組件

3.1 創建組件文件

檔案: frontend-react/src/components/D3Timeline.tsx

import { useEffect, useRef, useState } 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;
  x: number;
  y: number;
  fx?: number | null;  // 固定 X事件點
  fy?: number | null;  // 固定 Y事件點在時間軸上
  event: Event;
  labelWidth: number;
  labelHeight: number;
}

export default function D3Timeline({ events, width = 1200, height = 600 }: D3TimelineProps) {
  const svgRef = useRef<SVGSVGElement>(null);
  const [simulation, setSimulation] = useState<d3.Simulation<Node, undefined> | null>(null);

  useEffect(() => {
    if (!svgRef.current || events.length === 0) return;

    // 清空 SVG
    const svg = d3.select(svgRef.current);
    svg.selectAll('*').remove();

    // 邊距設定
    const margin = { top: 100, right: 50, bottom: 50, left: 50 };
    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 labelWidth = Math.max(event.title.length * 8, 120);
      const labelHeight = 60;
      const initialY = event.layer % 2 === 0 ? axisY - 150 : axisY + 150;

      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  // 對應的事件點
      }));

    // D3 力導向模擬
    const sim = 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') {
          return d.y < axisY ? axisY - 120 : axisY + 120;
        }
        return axisY;
      }).strength(0.3))
      // 5. 邊界限制
      .on('tick', () => {
        nodes.forEach(d => {
          if (d.type === 'label') {
            // 限制 Y 範圍
            if (d.y! < 20) d.y = 20;
            if (d.y! > innerHeight - 20) d.y = innerHeight - 20;

            // 限制 X 範圍(允許小幅偏移)
            const eventNode = nodes.find(n => n.type === 'event' && n.eventId === d.eventId)!;
            const maxOffset = 80;
            if (Math.abs(d.x! - eventNode.x!) > maxOffset) {
              d.x = eventNode.x! + (d.x! > eventNode.x! ? maxOffset : -maxOffset);
            }
          }
        });

        updateVisualization();
      });

    setSimulation(sim);

    // 繪製可視化元素
    function updateVisualization() {
      // 連接線
      const linkElements = 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.id))!;
          return source.x!;
        })
        .attr('y1', d => {
          const source = nodes.find(n => n.id === (typeof d.source === 'number' ? d.source : d.source.id))!;
          return source.y!;
        })
        .attr('x2', d => {
          const target = nodes.find(n => n.id === (typeof d.target === 'number' ? d.target : d.target.id))!;
          return target.x!;
        })
        .attr('y2', d => {
          const target = nodes.find(n => n.id === (typeof d.target === 'number' ? d.target : d.target.id))!;
          return target.y!;
        })
        .attr('stroke', '#94a3b8')
        .attr('stroke-width', 1.5)
        .attr('opacity', 0.7);

      // 事件點
      const eventNodes = 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('text')
        .data(d => [d])
        .join('text')
        .attr('x', d => d.labelWidth / 2)
        .attr('y', 20)
        .attr('text-anchor', 'middle')
        .attr('font-size', 12)
        .attr('font-weight', 'bold')
        .text(d => d.event.title);
    }

    // 初始繪製
    updateVisualization();

    // 清理函數
    return () => {
      sim.stop();
    };
  }, [events, width, height]);

  return (
    <div className="border border-gray-200 rounded-lg overflow-hidden bg-white">
      <svg
        ref={svgRef}
        width={width}
        height={height}
        className="w-full h-auto"
      />
    </div>
  );
}

🔗 步驟 4: 整合到 App.tsx

檔案: frontend-react/src/App.tsx

import { useState, useCallback } from 'react';
import D3Timeline from './components/D3Timeline';
import { timelineAPI } from './api/timeline';

function App() {
  const [events, setEvents] = useState<any[]>([]);
  const [renderMode, setRenderMode] = useState<'d3' | 'plotly'>('d3');

  // ... 其他現有代碼 ...

  // 載入原始事件資料for D3
  const loadEventsForD3 = async () => {
    try {
      const response = await axios.get('http://localhost:8000/api/events/raw');
      if (response.data.success) {
        setEvents(response.data.events);
      }
    } catch (error) {
      console.error('Failed to load events:', error);
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-primary-500 to-secondary-500 p-6">
      {/* ... 現有代碼 ... */}

      {/* 渲染模式切換 */}
      <div className="card">
        <div className="flex gap-4 mb-4">
          <button
            onClick={() => setRenderMode('d3')}
            className={renderMode === 'd3' ? 'btn-primary' : 'btn-secondary'}
          >
            D3.js 力導向
          </button>
          <button
            onClick={() => setRenderMode('plotly')}
            className={renderMode === 'plotly' ? 'btn-primary' : 'btn-secondary'}
          >
            Plotly(舊版)
          </button>
        </div>

        {renderMode === 'd3' && events.length > 0 && (
          <D3Timeline events={events} />
        )}

        {renderMode === 'plotly' && plotlyData && (
          <Plot data={plotlyData.data} layout={plotlyLayout} />
        )}
      </div>
    </div>
  );
}

🎯 關鍵技術點

1. 固定事件點位置

{
  fx: eventX,  // 固定 X - 保證時間準確性
  fy: axisY,   // 固定 Y - 在時間軸上
}

2. 碰撞力(避免重疊)

.force('collide', d3.forceCollide<Node>()
  .radius(d => Math.max(d.labelWidth / 2, d.labelHeight / 2) + 10)
  .strength(0.8)
)

3. 連結力(彈簧效果)

.force('link', d3.forceLink(links)
  .distance(100)   // 理想距離
  .strength(0.3)   // 彈簧強度
)

4. 限制標籤 X 偏移

const maxOffset = 80;
if (Math.abs(labelX - eventX) > maxOffset) {
  labelX = eventX + (labelX > eventX ? maxOffset : -maxOffset);
}

📊 優勢對比

項目 Plotly 後端 D3 Force 前端
標籤避讓效果 ⚠️ 專業
動態調整 需重新渲染 即時模擬
性能 ⚠️ 後端計算 瀏覽器端
可定制性 有限 完全控制
開發成本 ⚠️ 中等

⏱️ 實施時程

  • 步驟 1: 安裝依賴 - 15分鐘
  • 步驟 2: 修改後端 API - 30分鐘
  • 步驟 3: 創建 D3 組件 - 2-3小時
  • 步驟 4: 整合到 App - 1小時
  • 測試調優: 1-2小時

總計: 半天到一天


🚀 下一步

您希望我:

A. 立即開始實施(按步驟執行) B. 先創建一個簡化版 POC 測試效果 C. 評估其他替代方案vis-timeline, react-calendar-timeline

請告訴我您的選擇!