Files
chinese-family-tree-2/app/timeline/page.tsx
T
freedakgmail 626d7dddde 0.0.0.5
2025-11-23 00:49:16 +08:00

492 lines
21 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Clock, Map as MapIcon, ArrowRight } from "lucide-react"
import dynamic from 'next/dynamic'
import { useMemo, useEffect, useState } from 'react'
import * as echarts from 'echarts'
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false })
// 中国地图 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]]]
]
}
}
]
}
// 城市坐标数据
const geoCoordMap: Record<string, [number, number]> = {
'广州': [113.23, 23.16],
'深圳': [114.07, 22.62],
'北京': [116.46, 39.92],
'上海': [121.48, 31.22],
'成都': [104.06, 30.67],
'杭州': [120.19, 30.26],
'南京': [118.78, 32.04],
'武汉': [114.31, 30.52],
'西安': [108.95, 34.27],
'厦门': [118.10, 24.46]
}
export default function TimelinePage() {
const { treeData, getMember } = useFamily()
const members = Object.values(treeData.members)
const [mapLoaded, setMapLoaded] = useState(false)
// 注册中国地图 - 从外部文件加载完整数据
useEffect(() => {
fetch('/china.json')
.then(response => response.json())
.then(geoJson => {
echarts.registerMap('china', geoJson)
setMapLoaded(true)
})
.catch(error => {
console.error('Failed to load China map:', error)
// 如果加载失败,使用简化版本
echarts.registerMap('china', chinaGeoJSON as any)
setMapLoaded(true)
})
}, [])
// -- Timeline Logic --
// Flatten events: Births and Deaths
const events = 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)) // 倒序排列:最新的在上面
// -- Migration Logic --
// Find moves between generations (Father -> Child location change)
const migrations = members
.flatMap((m) => {
if (!m.fatherId) return []
const father = getMember(m.fatherId)
if (!father) return []
// Check if location exists and is different
const loc1 = father.ancestralHome || father.birthPlace
const loc2 = m.ancestralHome || m.birthPlace
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))
// ECharts 地图配置
const mapOption = useMemo(() => {
// 城市坐标映射(简化城市名)
const cityMap: Record<string, string> = {
'广东省广州市': '广州',
'广东省深圳市': '深圳',
'北京市': '北京',
'上海市': '上海',
'四川省成都市': '成都',
}
// 转换迁徙数据为 ECharts 格式(使用经纬度坐标)
const lines = migrations.map(mig => {
const fromCity = cityMap[mig.from] || mig.from
const toCity = cityMap[mig.to] || mig.to
const fromCoord = geoCoordMap[fromCity]
const toCoord = geoCoordMap[toCity]
if (!fromCoord || !toCoord) return null
return {
fromName: fromCity,
toName: toCity,
coords: [fromCoord, toCoord]
}
}).filter(Boolean)
// 统计每个城市的迁入迁出
const cityData: Record<string, number> = {}
migrations.forEach(mig => {
const from = cityMap[mig.from] || mig.from
const to = cityMap[mig.to] || 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,
symbol: 'arrow',
symbolSize: 10,
color: 'hsl(var(--primary))'
},
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])
return (
<div className="min-h-screen bg-background flex flex-col font-sans">
<SiteHeader />
<main className="container mx-auto py-8 px-4 md:px-6 flex-1">
<div className="mb-8">
<h1 className="text-3xl font-serif font-bold mb-2"> (History)</h1>
<p className="text-muted-foreground"></p>
</div>
<Tabs defaultValue="timeline" className="w-full">
<TabsList className="mb-6 w-full md:w-auto">
<TabsTrigger value="timeline" className="flex items-center gap-2">
<Clock className="h-4 w-4" /> (Chronology)
</TabsTrigger>
<TabsTrigger value="migration" className="flex items-center gap-2">
<MapIcon className="h-4 w-4" /> (Migration)
</TabsTrigger>
</TabsList>
<TabsContent value="timeline">
<Card className="border-none shadow-none bg-transparent">
<CardContent 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">{event.member.fullName}</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">
:{getMember(event.member.fatherId)?.fullName || "未知"}
</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">
:{getMember(event.member.motherId)?.fullName || "未知"}
</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">
:{getMember(event.member.spouseIds[0])?.fullName || "未知"}
</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 text-muted-foreground italic"></div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="migration">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card>
<CardHeader>
<CardTitle className="font-serif"> (Records)</CardTitle>
</CardHeader>
<CardContent>
<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">{mig.father.fullName}</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">{mig.person.fullName}</div>
</div>
</div>
</div>
))}
{migrations.length === 0 && (
<p className="text-center text-muted-foreground py-10">
<br />
/
</p>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-primary/5 via-background to-secondary/5 border-primary/20">
<CardHeader>
<CardTitle className="font-serif"> (Migration Flow)</CardTitle>
</CardHeader>
<CardContent>
<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>
) : migrations.length === 0 ? (
<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" />
<p></p>
<p className="text-sm mt-2"></p>
</div>
</div>
) : (
<ReactECharts
key="migration-map"
option={mapOption}
style={{ height: '100%', width: '100%' }}
opts={{ renderer: 'canvas' }}
notMerge={true}
lazyUpdate={true}
/>
)}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</main>
</div>
)
}