Files
chinese-family-tree-2/app/timeline/page.tsx
T
freedakgmail 86e889cc2a 0.3.1.2
2025-11-30 19:10:30 +08:00

805 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { SiteHeader } from "@/components/site-header"
import { useFamily } from "@/context/family-context"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ChineseCard, ChineseCardContent, ChineseCardHeader, ChineseCardTitle } from "@/components/ui/chinese-card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Clock, Map as MapIcon, ArrowRight, Calendar, User, Loader2 } from 'lucide-react'
import { useMemo, useEffect, useState, useRef, useCallback } from 'react'
import { MemberNameWithStatus } from "@/components/member-name-with-status"
import { useSession } from 'next-auth/react'
import { EmptyStateBadge } from '@/components/empty-state-badge'
import { useRouter } from 'next/navigation'
import type { ECharts } from 'echarts'
// 中国地图 GeoJSON 数据 - 完整的轮廓
const chinaGeoJSON = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { name: "中国" },
geometry: {
type: "MultiPolygon",
coordinates: [
[
[
// 中国大陆完整轮廓 - 从东北开始逆时针
[134.5, 48.5], [135, 47], [134, 45], [133, 43], [131, 42], [130, 43], [129, 44],
[128, 45], [127, 46], [126, 47], [125, 48], [124, 49], [123.5, 50], [123, 51],
[122.5, 52], [122, 53], [121, 53.5], [120, 53.3], [119, 53], [118.5, 52],
[119, 51], [119.5, 50], [120, 49], [121, 48], [122, 47], [123, 46], [124, 45],
[125, 44], [126, 43], [127, 42.5], [128, 42], [129, 41.5], [130, 41],
// 东部海岸线
[130, 40], [129, 39], [128, 38.5], [127, 38], [126, 37.5], [125, 37],
[124, 36.5], [123, 36], [122.5, 35.5], [122, 35], [121.5, 34.5], [121, 34],
[120.5, 33.5], [120, 33], [119.5, 32.5], [119.5, 32], [120, 31.5], [120.5, 31],
[121, 30.5], [121.5, 30], [122, 29.5], [122, 29], [121.5, 28.5], [121, 28],
[120.5, 27.5], [120, 27], [119.5, 26.5], [119, 26], [118.5, 25.5], [118, 25],
[117.5, 24.5], [117, 24], [116.5, 23.5], [116, 23], [115.5, 22.5], [115, 22],
[114, 21.8], [113, 21.5], [112, 21.3], [111, 21.2], [110.5, 21],
// 南部边界
[110, 20.5], [109.5, 20], [109, 20.5], [108.5, 21], [108, 21.5], [107.5, 22],
[107, 22.5], [106.5, 23], [106, 23.5], [105.5, 24], [105, 24.5], [104.5, 25],
[104, 25.5], [103.5, 26], [103, 26.5], [102.5, 27], [102, 27.5], [101.5, 28],
[101, 28.5], [100.5, 29], [100, 29.5], [99.5, 30], [99, 30.5], [98.5, 31],
[98, 31.5], [97.5, 32], [97, 32.5],
// 西南边界
[96.5, 33], [96, 33.5], [95.5, 34], [95, 34.5], [94.5, 35], [94, 35.5],
[93.5, 36], [93, 36.5], [92.5, 37], [92, 37.5], [91.5, 38], [91, 38.5],
[90.5, 39], [90, 39.5], [89.5, 40], [89, 40.5], [88.5, 41], [88, 41.5],
[87.5, 42], [87, 42.5], [86.5, 43], [86, 43.5], [85.5, 44], [85, 44.5],
[84.5, 45], [84, 45.5], [83.5, 46], [83, 46.5], [82.5, 47], [82, 47.5],
[81.5, 48], [81, 48.5], [80.5, 49], [80, 49.5],
// 西北边界
[79.5, 49.5], [79, 49.3], [78.5, 49], [78, 48.5], [77.5, 48], [77, 47.5],
[76.5, 47], [76, 46.5], [75.5, 46], [75, 45.5], [74.5, 45], [74, 44.5],
[73.5, 44], [73.5, 43.5], [74, 43], [74.5, 42.5], [75, 42], [75.5, 41.5],
[76, 41], [76.5, 40.5], [77, 40], [77.5, 39.5], [78, 39], [78.5, 38.5],
[79, 38.5], [79.5, 39], [80, 39.5], [80.5, 40], [81, 40.5], [81.5, 41],
[82, 41.5], [82.5, 42], [83, 42.5], [83.5, 43], [84, 43.5], [84.5, 44],
[85, 44.5], [85.5, 45], [86, 45.5], [86.5, 46], [87, 46.5], [87.5, 47],
[88, 47.5], [88.5, 48], [89, 48.5], [89.5, 49], [90, 49.5], [90.5, 50],
[91, 50.5], [91.5, 51], [92, 51.5], [92.5, 52], [93, 52.5], [93.5, 53],
// 北部边界
[94, 53], [95, 52.8], [96, 52.5], [97, 52.2], [98, 52], [99, 51.8],
[100, 51.5], [101, 51.2], [102, 51], [103, 50.8], [104, 50.5], [105, 50.2],
[106, 50], [107, 49.8], [108, 49.5], [109, 49.3], [110, 49.2], [111, 49.1],
[112, 49], [113, 48.9], [114, 48.8], [115, 48.7], [116, 48.6], [117, 48.5],
[118, 48.5], [119, 48.5], [120, 48.6], [121, 48.7], [122, 48.8], [123, 48.9],
[124, 49], [125, 49.1], [126, 49.2], [127, 49.3], [128, 49.4], [129, 49.5],
[130, 49.5], [131, 49.4], [132, 49.2], [133, 49], [134, 48.8], [134.5, 48.5]
]
],
// 海南岛
[[[108.6, 18.5], [109.5, 18.3], [110.5, 18.5], [111, 19], [111, 19.5], [110.5, 20], [109.5, 20.2], [108.6, 20], [108.2, 19.5], [108.6, 18.5]]],
// 台湾岛
[[[120, 22], [120.5, 21.9], [121, 22], [121.5, 22.5], [122, 23], [122, 23.5], [122, 24], [121.8, 24.5], [121.5, 25], [121, 25.2], [120.5, 25.1], [120.2, 24.8], [120, 24.5], [120, 24], [120, 23.5], [120, 23], [120, 22.5], [120, 22]]]
]
}
}
]
}
// 主要城市坐标(包含1、2、3线城市)
const geoCoordMap: Record<string, [number, number]> = {
// 一线城市
'北京': [116.46, 39.92],
'上海': [121.48, 31.22],
'广州': [113.23, 23.16],
'深圳': [114.07, 22.62],
// 新一线城市
'成都': [104.06, 30.67],
'杭州': [120.19, 30.26],
'重庆': [106.55, 29.56],
'西安': [108.95, 34.27],
'苏州': [120.62, 31.32],
'武汉': [114.31, 30.52],
'南京': [118.78, 32.04],
'天津': [117.20, 39.13],
'郑州': [113.65, 34.76],
'长沙': [112.94, 28.23],
'东莞': [113.75, 23.05],
'沈阳': [123.43, 41.80],
'青岛': [120.38, 36.07],
'合肥': [117.27, 31.86],
'佛山': [113.12, 23.02],
// 二线城市
'昆明': [102.73, 25.04],
'福州': [119.30, 26.08],
'无锡': [120.29, 31.57],
'厦门': [118.10, 24.46],
'哈尔滨': [126.63, 45.75],
'长春': [125.35, 43.88],
'南昌': [115.89, 28.68],
'济南': [117.12, 36.65],
'宁波': [121.55, 29.87],
'大连': [121.62, 38.91],
'贵阳': [106.71, 26.57],
'温州': [120.67, 28.00],
'石家庄': [114.52, 38.05],
'泉州': [118.58, 24.93],
'南宁': [108.33, 22.84],
'金华': [119.65, 29.08],
'常州': [119.95, 31.79],
'珠海': [113.52, 22.30],
'惠州': [114.42, 23.09],
'嘉兴': [120.76, 30.77],
'南通': [120.86, 32.01],
'中山': [113.38, 22.52],
'保定': [115.48, 38.85],
'太原': [112.55, 37.87],
'兰州': [103.79, 36.06],
'台州': [121.43, 28.66],
// 三线城市
'乌鲁木齐': [87.68, 43.77],
'绍兴': [120.58, 30.03],
'廊坊': [116.70, 39.54],
'洛阳': [112.45, 34.62],
'威海': [122.12, 37.52],
'盐城': [120.13, 33.38],
'临沂': [118.35, 35.05],
'江门': [113.08, 22.61],
'汕头': [116.69, 23.39],
'泰州': [119.90, 32.49],
'漳州': [117.65, 24.52],
'邯郸': [114.47, 36.60],
'济宁': [116.59, 35.38],
'芜湖': [118.38, 31.33],
'淄博': [118.05, 36.78],
'银川': [106.27, 38.47],
'柳州': [109.40, 24.33],
'绵阳': [104.73, 31.48],
'湖州': [120.10, 30.86],
'衡阳': [112.61, 26.89],
'莆田': [119.01, 25.43],
'宜昌': [111.29, 30.70],
'桂林': [110.28, 25.29],
'三亚': [109.51, 18.25],
'遵义': [106.93, 27.71],
'咸阳': [108.71, 34.33],
'上饶': [117.97, 28.45],
'莱芜': [117.68, 36.21],
'赣州': [114.94, 25.85],
'揭阳': [116.35, 23.55],
'扬州': [119.42, 32.39],
'菏泽': [115.47, 35.23],
'徐州': [117.20, 34.26],
'肇庆': [112.47, 23.05],
'海口': [110.33, 20.02],
'拉萨': [91.13, 29.66],
'呼和浩特': [111.75, 40.84],
'包头': [109.84, 40.66],
'南阳': [112.53, 33.00],
'鞍山': [122.99, 41.12],
'九江': [115.99, 29.71],
'大庆': [125.11, 46.59],
'平顶山': [113.31, 33.74],
'抚顺': [123.92, 41.88],
'秦皇岛': [119.57, 39.95],
'株洲': [113.15, 27.83],
'齐齐哈尔': [123.95, 47.33]
}
export default function TimelinePage() {
const { treeData, currentTree, getMember, isLoading } = useFamily()
const { data: session, status } = useSession()
const members = Object.values(treeData.members)
const [mapLoaded, setMapLoaded] = useState(false)
const treeId = currentTree?.id
const router = useRouter()
const chartInstanceRef = useRef<ECharts | null>(null)
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
// 注册中国地图 - 从外部文件加载完整数据(必须在条件渲染之前)
useEffect(() => {
let isMounted = true
// 动态导入 echarts 并注册地图
const loadMap = async () => {
try {
const echarts = await import('echarts')
try {
const response = await fetch('/china.json')
const geoJson = await response.json()
if (isMounted) {
echarts.registerMap('china', geoJson)
setMapLoaded(true)
}
} catch (fetchError) {
console.error('Failed to load China map JSON, using fallback:', fetchError)
// 如果加载失败,使用简化版本
if (isMounted) {
echarts.registerMap('china', chinaGeoJSON as any)
setMapLoaded(true)
}
}
} catch (importError) {
console.error('Failed to import echarts:', importError)
}
}
loadMap()
return () => {
isMounted = false
}
}, [])
// -- Timeline Logic -- (必须在条件渲染之前)
// Flatten events: Births and Deaths
const events = useMemo(() => members
.flatMap((m) => {
const evs = []
if (m.birthDate) {
evs.push({
date: m.birthDate,
year: Number.parseInt(m.birthDate.substring(0, 4)),
type: "birth",
member: m,
})
}
if (m.deathDate) {
evs.push({
date: m.deathDate,
year: Number.parseInt(m.deathDate.substring(0, 4)),
type: "death",
member: m,
})
}
return evs
})
.sort((a, b) => b.date.localeCompare(a.date)), [members]) // 倒序排列:最新的在上面
// -- Migration Logic --
// Find moves between generations (Father -> Child location change)
const migrations = useMemo(() => members
.flatMap((m) => {
if (!m.fatherId) return []
const father = getMember(m.fatherId)
if (!father) return []
// Check if location exists and is different
// 优先使用 birthPlace(出生地)来判断迁徙,因为 ancestralHome(祖籍)通常不变
const loc1 = father.birthPlace || father.ancestralHome
const loc2 = m.birthPlace || m.ancestralHome
if (loc1 && loc2 && loc1 !== loc2) {
return [
{
year: m.birthDate ? Number.parseInt(m.birthDate.substring(0, 4)) : null,
from: loc1,
to: loc2,
person: m,
father: father,
},
]
}
return []
})
.sort((a, b) => (a.year || 0) - (b.year || 0)), [members, getMember])
// ECharts 地图配置
const mapOption = useMemo(() => {
// 城市坐标映射(简化城市名)
// 城市名称映射(支持省+市格式)
const cityMap: Record<string, string> = {}
// 自动生成映射:将"XX省XX市"或"XX市"映射到城市名
Object.keys(geoCoordMap).forEach(city => {
cityMap[city] = city // 直接城市名
cityMap[`${city}`] = city // 城市+市
// 根据城市所在省份添加映射
const provinceMap: Record<string, string[]> = {
'广东省': ['广州', '深圳', '东莞', '佛山', '珠海', '惠州', '中山', '江门', '汕头', '肇庆'],
'浙江省': ['杭州', '宁波', '温州', '金华', '嘉兴', '台州', '绍兴', '湖州'],
'江苏省': ['苏州', '南京', '无锡', '常州', '南通', '盐城', '泰州', '扬州', '徐州'],
'山东省': ['济南', '青岛', '威海', '临沂', '济宁', '淄博', '莱芜', '菏泽'],
'四川省': ['成都', '绵阳'],
'河北省': ['石家庄', '保定', '廊坊', '邯郸', '秦皇岛'],
'福建省': ['福州', '厦门', '泉州', '莆田', '漳州'],
'湖北省': ['武汉', '宜昌'],
'湖南省': ['长沙', '衡阳', '株洲'],
'陕西省': ['西安', '咸阳'],
'河南省': ['郑州', '洛阳', '南阳', '平顶山'],
'辽宁省': ['沈阳', '大连', '鞍山', '抚顺'],
'安徽省': ['合肥', '芜湖'],
'云南省': ['昆明'],
'贵州省': ['贵阳', '遵义'],
'广西壮族自治区': ['南宁', '柳州', '桂林'],
'江西省': ['南昌', '九江', '赣州', '上饶'],
'黑龙江省': ['哈尔滨', '大庆', '齐齐哈尔'],
'吉林省': ['长春'],
'山西省': ['太原'],
'甘肃省': ['兰州'],
'新疆维吾尔自治区': ['乌鲁木齐'],
'宁夏回族自治区': ['银川'],
'内蒙古自治区': ['呼和浩特', '包头'],
'海南省': ['海口', '三亚'],
'西藏自治区': ['拉萨'],
'天津市': ['天津'],
'重庆市': ['重庆'],
'北京市': ['北京'],
'上海市': ['上海']
}
// 为每个城市添加"省+市"格式的映射
Object.entries(provinceMap).forEach(([province, cities]) => {
if (cities.includes(city)) {
cityMap[`${province}${city}`] = city
cityMap[`${province}${city}`] = city
}
})
})
// 提取城市名称的辅助函数(去掉区的部分)
const extractCity = (location: string): string => {
// 尝试从 cityMap 中查找
let city = cityMap[location]
if (city && geoCoordMap[city]) return city
// 如果没找到,尝试提取省市部分(去掉区)
// 例如:"山东省济南市历下区" -> "济南"
const match = location.match(/(.+?省)?(.+?市)/)
if (match) {
const province = match[1] || ''
const cityName = match[2] || ''
// 尝试多种组合
const attempts = [
location,
province + cityName,
cityName.replace('市', ''),
cityName
]
for (const attempt of attempts) {
const mapped = cityMap[attempt]
if (mapped && geoCoordMap[mapped]) return mapped
}
}
return location
}
// 转换迁徙数据为 ECharts 格式(使用经纬度坐标)
const lines = migrations.map(mig => {
const fromCity = extractCity(mig.from)
const toCity = extractCity(mig.to)
const fromCoord = geoCoordMap[fromCity]
const toCoord = geoCoordMap[toCity]
if (!fromCoord || !toCoord) {
console.log('坐标未找到:', {
from: mig.from,
to: mig.to,
fromCity,
toCity,
fromCoord,
toCoord
})
return null
}
return {
fromName: fromCity,
toName: toCity,
coords: [fromCoord, toCoord]
}
}).filter(Boolean)
console.log('迁徙数据:', { migrations, lines })
// 统计每个城市的迁入迁出
const cityData: Record<string, number> = {}
migrations.forEach(mig => {
const from = extractCity(mig.from)
const to = extractCity(mig.to)
cityData[from] = (cityData[from] || 0) + 1
cityData[to] = (cityData[to] || 0) + 1
})
const scatterData = Object.entries(cityData).map(([name, value]) => {
const coord = geoCoordMap[name]
if (!coord) return null
return {
name,
value: [...coord, value * 10]
}
}).filter(Boolean)
return {
backgroundColor: 'transparent',
geo: {
map: 'china',
roam: true,
label: {
show: true,
color: '#666',
fontSize: 12
},
itemStyle: {
areaColor: 'rgba(128, 128, 128, 0.15)',
borderColor: 'rgba(128, 128, 128, 0.5)',
borderWidth: 2
},
emphasis: {
itemStyle: {
areaColor: 'rgba(128, 128, 128, 0.25)'
},
label: {
show: true,
color: '#333',
fontSize: 14
}
}
},
series: [
{
type: 'lines',
coordinateSystem: 'geo',
data: lines,
lineStyle: {
color: 'transparent',
width: 0,
opacity: 0
},
effect: {
show: true,
period: 3,
trailLength: 0.2,
symbol: 'arrow',
symbolSize: 12,
color: '#3b82f6',
loop: true
},
zlevel: 1
},
{
type: 'scatter',
coordinateSystem: 'geo',
data: scatterData,
symbolSize: 10,
label: {
show: true,
formatter: '{b}',
position: 'right',
fontSize: 12,
fontWeight: 'normal',
color: '#333'
},
itemStyle: {
color: '#000',
shadowBlur: 0,
borderWidth: 0
},
emphasis: {
scale: true,
itemStyle: {
color: '#000'
},
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
}
}
]
}
}, [migrations])
// 使用 callback ref 来确保 DOM 元素挂载后初始化图表
const initChartRef = useCallback((node: HTMLDivElement | null) => {
if (!node || !mapLoaded) {
console.log('initChartRef: node或mapLoaded不存在', { node: !!node, mapLoaded })
return
}
console.log('initChartRef: 开始初始化图表')
// 动态导入并初始化图表
import('echarts').then((echarts) => {
// 再次检查节点是否存在
if (!node) return
// 检查是否已有实例
let chart = echarts.getInstanceByDom(node)
if (!chart) {
console.log('创建新的图表实例')
// 创建新实例
chart = echarts.init(node)
chartInstanceRef.current = chart
// 监听窗口大小变化
const handleResize = () => {
chart?.resize()
}
window.addEventListener('resize', handleResize)
// 保存清理函数到节点
;(node as any).__resizeHandler = handleResize
} else {
console.log('使用已有的图表实例')
}
// 设置选项
console.log('设置图表选项')
chart.setOption(mapOption, { notMerge: true })
console.log('图表选项设置完成')
}).catch(error => {
console.error('Failed to initialize chart:', error)
})
}, [mapLoaded, mapOption])
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
}
// 显示加载状态
if (isLoading) {
return (
<div className="min-h-screen bg-background flex flex-col">
<SiteHeader />
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">...</p>
</div>
</div>
</div>
)
}
// 显示无数据状态
if (members.length === 0) {
return (
<div className="min-h-screen bg-background flex flex-col">
<SiteHeader />
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 text-primary/80">
<Calendar className="h-16 w-16" strokeWidth={1} />
</div>
<p className="text-base text-foreground font-light tracking-wide"></p>
</div>
</div>
</div>
)
}
return (
<div className="h-screen bg-background flex flex-col font-sans overflow-hidden">
<SiteHeader />
<main className="container mx-auto py-8 px-4 md:px-6 flex-1 flex flex-col overflow-hidden">
<div className="mb-8 flex-shrink-0">
<h1 className="text-3xl font-serif font-bold mb-2"></h1>
<p className="text-muted-foreground"></p>
</div>
<Tabs defaultValue="timeline" className="w-full flex-1 flex flex-col overflow-hidden">
<TabsList className="mb-6 w-full md:w-auto flex-shrink-0">
<TabsTrigger value="timeline" className="flex items-center gap-2">
<Clock className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="migration" className="flex items-center gap-2">
<MapIcon className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="timeline" className="flex-1 overflow-y-auto">
<ChineseCard variant="scroll" className="border-none shadow-none bg-transparent">
<ChineseCardContent className="p-0">
<div className="relative border-l-2 border-primary/20 ml-6 md:ml-12 space-y-8 py-4">
{events.map((event, idx) => (
<div key={idx} className="relative pl-8 md:pl-12 group">
{/* Dot */}
<div
className={`absolute -left-[9px] top-1 h-4 w-4 rounded-full border-2 border-background transition-colors group-hover:scale-110 ${event.type === "birth" ? "bg-primary" : "bg-neutral-400"
}`}
/>
{/* Year Label (Mobile vs Desktop) */}
<div className="absolute -left-16 md:-left-24 top-0 w-12 md:w-20 text-right text-sm font-mono text-muted-foreground font-bold">
{event.year}
</div>
{/* Content Card */}
<div className="bg-card rounded-lg px-4 py-2 transition-all hover:bg-accent/5 border border-border/50">
<div className="flex items-center gap-2 flex-wrap text-sm">
<span
className={`text-xs px-2 py-0.5 rounded-full flex-shrink-0 ${event.type === "birth" ? "bg-primary/10 text-primary" : "bg-neutral-100 text-neutral-500"
}`}
>
{event.type === "birth" ? "诞生" : "逝世"}
</span>
<span className="font-serif font-bold">
<MemberNameWithStatus
name={event.member.fullName}
isDead={!!event.member.deathDate}
memberId={event.member.id}
treeId={treeId}
/>
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${event.member.gender === "MALE" ? "bg-blue-100 text-blue-700" : "bg-pink-100 text-pink-700"}`}>
{event.member.gender === "MALE" ? "男" : "女"}
</span>
<span className="text-xs text-muted-foreground">{event.member.generation}</span>
{/* 父母信息 */}
{event.member.fatherId && (
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
:<MemberNameWithStatus
name={getMember(event.member.fatherId)?.fullName || "未知"}
isDead={!!getMember(event.member.fatherId)?.deathDate}
memberId={event.member.fatherId}
treeId={treeId}
/>
</span>
)}
{event.member.motherId && (
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
:<MemberNameWithStatus
name={getMember(event.member.motherId)?.fullName || "未知"}
isDead={!!getMember(event.member.motherId)?.deathDate}
memberId={event.member.motherId}
treeId={treeId}
/>
</span>
)}
{/* 配偶信息 */}
{event.member.spouseIds && event.member.spouseIds.length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200">
:<MemberNameWithStatus
name={getMember(event.member.spouseIds[0])?.fullName || "未知"}
isDead={!!getMember(event.member.spouseIds[0])?.deathDate}
memberId={event.member.spouseIds[0]}
treeId={treeId}
/>
</span>
)}
{/* 子女信息 */}
{event.member.childrenIds && event.member.childrenIds.length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-50 text-green-700 border border-green-200">
:{event.member.childrenIds.length}
</span>
)}
{/* 出生地信息 */}
{event.type === "birth" && (event.member.birthPlace || event.member.ancestralHome) && (
<span className="text-xs text-muted-foreground">
📍{event.member.birthPlace || event.member.ancestralHome}
</span>
)}
{/* 享年 */}
{event.type === "death" && event.member.birthDate && (
<span className="text-xs text-muted-foreground">
{event.year - Number.parseInt(event.member.birthDate.substring(0, 4))}
</span>
)}
{/* 葬地 */}
{event.type === "death" && event.member.burialPlace && (
<span className="text-xs text-muted-foreground">
{event.member.burialPlace}
</span>
)}
<span className="text-xs text-muted-foreground font-mono ml-auto">{event.date}</span>
</div>
</div>
</div>
))}
{events.length === 0 && (
<div className="pl-8 py-8">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</div>
</ChineseCardContent>
</ChineseCard>
</TabsContent>
<TabsContent value="migration" className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<ChineseCard variant="bamboo" className="shadow-sm">
<ChineseCardHeader>
<ChineseCardTitle className="font-serif"></ChineseCardTitle>
</ChineseCardHeader>
<ChineseCardContent>
<ScrollArea className="h-[600px] pr-4">
<div className="space-y-6">
{migrations.map((mig, idx) => (
<div key={idx} className="flex flex-col gap-2 p-4 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<span className="font-mono bg-background px-1 rounded">
{mig.year ? `${mig.year}` : "未知年代"}
</span>
<span> {mig.person.generation} </span>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 text-center p-2 bg-background rounded border border-border/50">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-bold">{mig.from}</div>
<div className="text-xs text-muted-foreground mt-1">
<MemberNameWithStatus
name={mig.father.fullName}
isDead={!!mig.father.deathDate}
memberId={mig.father.id}
treeId={treeId}
/>
</div>
</div>
<ArrowRight className="text-muted-foreground" />
<div className="flex-1 text-center p-2 bg-background rounded border border-border/50">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-bold text-primary">{mig.to}</div>
<div className="text-xs text-muted-foreground mt-1">
<MemberNameWithStatus
name={mig.person.fullName}
isDead={!!mig.person.deathDate}
memberId={mig.person.id}
treeId={treeId}
/>
</div>
</div>
</div>
</div>
))}
{migrations.length === 0 && (
<div className="text-center py-16">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</div>
</ScrollArea>
</ChineseCardContent>
</ChineseCard>
<ChineseCard variant="default" cornerOrnament className="bg-gradient-to-br from-primary/5 via-background to-secondary/5 border-primary/20 shadow-sm">
<ChineseCardHeader>
<ChineseCardTitle className="font-serif"></ChineseCardTitle>
</ChineseCardHeader>
<ChineseCardContent>
<div className="relative h-[600px] overflow-hidden rounded-lg bg-background/50 backdrop-blur border border-border/50">
{!mapLoaded ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<MapIcon className="h-16 w-16 mx-auto mb-4 opacity-20 animate-pulse" />
<p>...</p>
</div>
</div>
) : (
<div
ref={initChartRef}
style={{ height: '100%', width: '100%' }}
/>
)}
</div>
</ChineseCardContent>
</ChineseCard>
</div>
</TabsContent>
</Tabs>
</main>
</div>
)
}