456 lines
19 KiB
TypeScript
456 lines
19 KiB
TypeScript
"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) => a.date.localeCompare(b.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">
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<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 text-base">{event.member.fullName}</span>
|
|
<span className="text-sm text-muted-foreground">第{event.member.generation}世</span>
|
|
{event.type === "birth" && (event.member.birthPlace || event.member.ancestralHome) && (
|
|
<span className="text-sm text-muted-foreground">
|
|
出生于 {event.member.birthPlace || event.member.ancestralHome}
|
|
</span>
|
|
)}
|
|
{event.type === "death" && event.member.birthDate && (
|
|
<span className="text-sm text-muted-foreground">
|
|
享年 {event.year - Number.parseInt(event.member.birthDate.substring(0, 4))} 岁
|
|
</span>
|
|
)}
|
|
{event.type === "death" && event.member.burialPlace && (
|
|
<span className="text-sm 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>
|
|
)
|
|
}
|