```javascript 'use client'; import React, { useState, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import * as Lucide from 'lucide-react'; import * as d3 from 'd3'; // ----------------------------------------------------------------------------- // Constants & Data // ----------------------------------------------------------------------------- const TAIPING_PATH = [ { name: "金田村", year: 1851, type: "uprising", desc: "洪秀全于广西桂平金田村发动起义,建号太平天国,以此为起点,燃起燎原之火。", coords: [110.08, 23.40] }, { name: "永安", year: 1851, type: "foundation", desc: "攻克永安(今蒙山),在此封王建制,确立东西南北翼五王,政权初具规模。", coords: [110.52, 24.20] }, { name: "全州", year: 1852, type: "tragedy", desc: "蓑衣渡之战,遭遇江忠源伏击,南王冯云山中炮牺牲,遭受重大挫折。", coords: [111.07, 25.93] }, { name: "长沙", year: 1852, type: "battle", desc: "血战长沙八十余日,萧朝贵阵亡。虽未克城,但在此扩充队伍,转战湖北。", coords: [112.93, 28.23] }, { name: "武昌", year: 1853, type: "conquest", desc: "攻克武汉三镇,拥众五十万,声威大震,清廷举朝震惊。", coords: [114.30, 30.57] }, { name: "九江", year: 1853, type: "advance", desc: "顺长江东下,攻克九江、安庆,截断长江漕运,直逼江南。", coords: [115.99, 29.70] }, { name: "天京", year: 1853, type: "capital", desc: "攻克南京,改名天京,定都于此。与清廷建立江南大营、江北大营对峙。", coords: [118.79, 32.06] }, { name: "北伐", year: 1853, type: "expedition", desc: "派林凤祥、李开芳北伐,孤军深入直隶,震动北京,最终因孤立无援而败。", coords: [117.20, 39.12] }, { name: "天京", year: 1856, type: "crisis", desc: "天京事变爆发,诸王内讧,杨秀清被杀,韦昌辉伏诛。天国元气大伤,由盛转衰。", coords: [118.79, 32.06] }, // 石达开路线补充 { name: "安庆", year: 1857, type: "split", desc: "翼王石达开受洪秀全猜忌,被迫离京,驻军安庆。后感形势所迫,决意率部远征,从此脱离天京。", coords: [117.05, 30.53] }, { name: "衢州", year: 1858, type: "march", desc: "转战浙江、福建,意图建立新基地,但因清军围堵,未能立足,遂转向江西。", coords: [118.86, 28.97] }, { name: "宝庆", year: 1859, type: "siege", desc: "石达开回师湖南,围攻宝庆(今邵阳)数月不下,粮尽援绝,被迫撤退至广西。", coords: [111.47, 27.24] }, { name: "庆远", year: 1860, type: "retreat", desc: "退入广西,占领庆远(今宜州)。意图休整,但因地形不利且人心涣散,难以久持。", coords: [108.65, 24.48] }, { name: "叙州", year: 1862, type: "advance", desc: "石达开率余部突入四川,经叙州(今宜宾),意图抢渡长江,建立四川根据地。", coords: [104.62, 28.77], speedMod: 0.7 }, { name: "大渡河", year: 1863, type: "tragedy", desc: "兵抵大渡河紫打地(安顺场),遭遇百年不遇洪水,后有追兵,前无退路。石达开舍命全三军,悲壮覆灭。", coords: [102.25, 29.25], speedMod: 0.5 }, // 回归天京结局 { name: "天京", year: 1864, type: "fall", desc: "次年,湘军攻破天京。洪秀全病逝,太平天国运动最终落下帷幕。", coords: [118.79, 32.06], speedMod: 0.6 } ]; // Configuration const BASE_SPEED = 140; // Pixels per second const HOLD_DURATION = 2800; // Increased slightly for better readability of the first slide const WIDTH = 1200; const HEIGHT = 800; const BG_COLOR = "#31313a"; // User requested background color const CHINA_GEOJSON_URL = "https://gist.githubusercontent.com/zhulinpinyu/8e18d57b3b18fb74e776/raw/efbb74cfea53decb1fe7d6bf279fd351c28c4810/china_simplify.json"; // ----------------------------------------------------------------------------- // Component // ----------------------------------------------------------------------------- export const TaipingFlowMap = () => { // 1. Data State const [geoData, setGeoData] = useState(null); const [loading, setLoading] = useState(true); // 2. Animation State const [activeNodeIndex, setActiveNodeIndex] = useState(-1); const [currentLength, setCurrentLength] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [isFinished, setIsFinished] = useState(false); // 3. Mutable Animation State const requestRef = useRef(); const lastTimeRef = useRef(0); const hasStartedRef = useRef(false); const stateRef = useRef({ phase: 'idle', currentLen: 0, targetLen: 0, nodeIndex: 0, holdStartTime: 0, totalPathLength: 0, nodeLengths: [], speedModifiers: [] }); const pathRef = useRef(null); // --------------------------------------------------------------------------- // Data Loading // --------------------------------------------------------------------------- useEffect(() => { let isMounted = true; fetch(CHINA_GEOJSON_URL) .then(res => res.json()) .then(data => { if (isMounted) { setGeoData(data); setLoading(false); } }) .catch(err => { console.error("Failed to load map", err); if (isMounted) setLoading(false); }); return () => { isMounted = false; }; }, []); // --------------------------------------------------------------------------- // D3 Projection & Path Generation // --------------------------------------------------------------------------- const { pathGenerator, projectedPath, pathSvgString } = useMemo(() => { if (!geoData) return {}; const proj = d3.geoMercator() .center([110.5, 30]) .scale(1900) .translate([WIDTH / 2, HEIGHT / 2]); const pathGen = d3.geoPath().projection(proj); const projectedPoints = TAIPING_PATH.map(p => { const [x, y] = proj(p.coords); return { ...p, x, y }; }); const lineGenerator = d3.line() .x(d => d.x) .y(d => d.y) .curve(d3.curveCatmullRom.alpha(0.5)); const pathString = lineGenerator(projectedPoints); return { pathGenerator: pathGen, projectedPath: projectedPoints, pathSvgString: pathString }; }, [geoData]); // --------------------------------------------------------------------------- // Path Measurement & Auto Start // --------------------------------------------------------------------------- useLayoutEffect(() => { if (pathRef.current && projectedPath && projectedPath.length > 0) { const pathEl = pathRef.current; const totalLen = pathEl.getTotalLength(); const lengths = []; const precision = 3000; const pathPoints = []; for (let i = 0; i <= precision; i++) { const l = (i / precision) * totalLen; const p = pathEl.getPointAtLength(l); pathPoints.push({ l, x: p.x, y: p.y }); } projectedPath.forEach((node, i) => { if (i === 0) { lengths.push(0); return; } let minDist = Infinity; let bestLen = 0; const startSearchIdx = lengths[i-1] ? Math.floor((lengths[i-1] / totalLen) * precision) : 0; for (let j = startSearchIdx; j < pathPoints.length; j++) { const pp = pathPoints[j]; const dist = Math.sqrt((pp.x - node.x)**2 + (pp.y - node.y)**2); if (dist < minDist) { minDist = dist; bestLen = pp.l; } if (minDist < 5 && dist > 20) break; } lengths.push(bestLen); }); stateRef.current.totalPathLength = totalLen; stateRef.current.nodeLengths = lengths; stateRef.current.speedModifiers = projectedPath.map(p => p.speedMod || 1.0); // Auto Start Logic - Modified to show Jintian (Index 0) first if (!hasStartedRef.current) { hasStartedRef.current = true; // Start exactly at the beginning stateRef.current.currentLen = 0; stateRef.current.nodeIndex = 0; stateRef.current.targetLen = 0; stateRef.current.phase = 'holding'; // Start by holding the first frame stateRef.current.holdStartTime = null; // Will be set in first animate frame setActiveNodeIndex(0); // Immediately show Jintian info setIsPlaying(true); requestRef.current = requestAnimationFrame(animate); } } }, [projectedPath, pathSvgString]); // --------------------------------------------------------------------------- // Animation Loop // --------------------------------------------------------------------------- const animate = useCallback((time) => { if (!lastTimeRef.current) lastTimeRef.current = time; const deltaTime = time - lastTimeRef.current; lastTimeRef.current = time; const state = stateRef.current; if (state.phase === 'moving') { const speedMod = state.speedModifiers[state.nodeIndex] || 1.0; const currentSpeed = BASE_SPEED * speedMod; const dist = (currentSpeed * deltaTime) / 1000; state.currentLen += dist; if (state.currentLen >= state.targetLen) { state.currentLen = state.targetLen; state.phase = 'holding'; state.holdStartTime = time; setActiveNodeIndex(state.nodeIndex); setCurrentLength(state.currentLen); } else { setCurrentLength(state.currentLen); } } else if (state.phase === 'holding') { // Ensure holdStartTime is set if this is the very first frame if (!state.holdStartTime) state.holdStartTime = time; if (time - state.holdStartTime > HOLD_DURATION) { const nextIndex = state.nodeIndex + 1; if (nextIndex < state.nodeLengths.length) { state.nodeIndex = nextIndex; state.targetLen = state.nodeLengths[nextIndex]; state.phase = 'moving'; } else { state.phase = 'finished'; setIsFinished(true); setIsPlaying(false); } } } if (state.phase !== 'finished') { requestRef.current = requestAnimationFrame(animate); } }, []); // Control handlers const togglePlay = () => { if (isFinished) { // Restart from Jintian stateRef.current.currentLen = 0; stateRef.current.nodeIndex = 0; stateRef.current.targetLen = 0; stateRef.current.phase = 'holding'; stateRef.current.holdStartTime = performance.now(); setActiveNodeIndex(0); setIsFinished(false); setIsPlaying(true); lastTimeRef.current = 0; requestRef.current = requestAnimationFrame(animate); } else { if (isPlaying) { setIsPlaying(false); if (requestRef.current) cancelAnimationFrame(requestRef.current); } else { setIsPlaying(true); lastTimeRef.current = 0; requestRef.current = requestAnimationFrame(animate); } } }; useEffect(() => { return () => { if (requestRef.current) cancelAnimationFrame(requestRef.current); }; }, []); // --------------------------------------------------------------------------- // Derived Render Data // --------------------------------------------------------------------------- const headPos = useMemo(() => { if (!pathRef.current || currentLength < 0) return projectedPath?.[0] || {x:0, y:0}; const p = pathRef.current.getPointAtLength(Math.max(0, currentLength)); return { x: p.x, y: p.y }; }, [currentLength, projectedPath]); // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- if (loading) { return (
历史数据载入中...
); } return (
{/* Background Tech Grid */}
{/* Vignette */}
{/* Title */}

太平天国

The Heavenly Kingdom (1851-1864)
{/* 1. Map Base */} {geoData.features.map((feature, i) => ( ))} {/* 2. Track Base */} {/* 3. The Main History Line */} {/* 4. Flow Texture */} {/* 5. Cities / Nodes */} {projectedPath.map((point, index) => { const isVisited = index <= activeNodeIndex; const isCurrent = index === activeNodeIndex; const isCapital = point.type === 'capital'; const isTragedy = point.type === 'tragedy'; return ( {/* Ripple */} {isCurrent && isPlaying && ( )} {/* Node Dot */} {/* City Name */} {isVisited && ( {point.name} )} ); })} {/* 6. The "Head" - Leading Spark */} {currentLength >= 0 && ( )} {/* Info Card */} {activeNodeIndex >= 0 && TAIPING_PATH[activeNodeIndex] && ( {/* Header */}
{TAIPING_PATH[activeNodeIndex].year}
{getEventTypeLabel(TAIPING_PATH[activeNodeIndex].type)}
{getEventIcon(TAIPING_PATH[activeNodeIndex].type, 26)}
{/* Content */}

{TAIPING_PATH[activeNodeIndex].name}

{TAIPING_PATH[activeNodeIndex].desc}

{/* Progress Indicator */}
)}
{/* Controls */}
); }; // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- function getEventTypeLabel(type) { const map = { 'uprising': '金田起义', 'foundation': '永安建制', 'siege': '围城苦战', 'tragedy': '将星陨落', 'march': '挥师远征', 'battle': '血战夺城', 'victory': '大捷', 'conquest': '攻克重镇', 'advance': '挺进', 'clash': '激战', 'capital': '定都天京', 'expedition': '北伐孤军', 'crisis': '天京事变', 'split': '石达开出走', 'retreat': '艰难转战', 'fall': '天国覆灭' }; return map[type] || '历史事件'; } function getEventIcon(type, size=24) { switch(type) { case 'uprising': return ; case 'capital': return ; case 'battle': case 'siege': case 'clash': return ; case 'tragedy': case 'fall': case 'crisis': return ; case 'conquest': case 'victory': return ; case 'expedition': case 'split': case 'march': case 'retreat': case 'advance': return ; default: return ; } } ```