feat: Heritage Globe - 中国文物全球分布地图

- Next.js + MapLibre GL 3D 地球
- CARTO dark-matter vector 底图
- 动态星空背景(闪烁、漂移、流星)
- 经纬网格线(graticule)
- 文物数据图层与朝代/类别筛选
- 中国风信息弹窗
- 中英双语 i18n
- LayerPanel + MapControls 浮动面板
This commit is contained in:
freedakgmail
2026-07-01 01:37:18 +08:00
commit a550d6d0fd
35 changed files with 11760 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+416
View File
@@ -0,0 +1,416 @@
# Heritage Globe - 中国文物全球分布地图
## 项目概述
基于 OpenGridWorks 技术架构,构建一个展示中国流散文物全球分布的交互式地图系统。
## 技术栈
### 核心框架
- **Next.js 15** - React 服务端渲染框架
- **TypeScript** - 类型安全
- **Tailwind CSS** - 样式系统
### 地图引擎
- **MapLibre GL JS 4.x** - 开源矢量地图引擎(Mapbox GL 的开源替代)
- **PMTiles** - 云原生瓦片格式,支持按需加载
### 数据层
- **GeoJSON** - 文物坐标数据格式
- **Tippecanoe** - GeoJSON → PMTiles 转换工具
### UI 组件
- **Radix UI** - 无障碍组件库(对话框、下拉菜单等)
- **Lucide React** - 图标库
### 国际化
- **next-intl** - Next.js 国际化解决方案
- 支持中文(简体)/ English 双语切换
- URL 路径自动添加语言前缀(/zh、/en)
## 项目结构
```
heritage-globe/
├── app/ # Next.js App Router
│ ├── [locale]/ # 国际化路由
│ │ ├── layout.tsx # 语言布局
│ │ └── page.tsx # 首页(地图页)
│ ├── layout.tsx # 根布局
│ └── globals.css # 全局样式
├── messages/ # 翻译文件
│ ├── zh.json # 中文翻译
│ └── en.json # 英文翻译
├── components/ # React 组件
│ ├── map/ # 地图相关组件
│ │ ├── MapContainer.tsx # 地图容器
│ │ ├── LayerControl.tsx # 图层控制
│ │ └── RelicMarker.tsx # 文物标记
│ ├── sidebar/ # 侧边栏组件
│ │ ├── Sidebar.tsx # 侧边栏容器
│ │ ├── FilterPanel.tsx # 筛选面板
│ │ └── SearchBox.tsx # 搜索框
│ └── ui/ # 通用 UI 组件
├── lib/ # 工具函数
│ ├── map-utils.ts # 地图工具函数
│ ├── pmtiles-loader.ts # PMTiles 加载器
│ └── data-processor.ts # 数据处理
├── public/ # 静态资源
│ ├── tiles/ # PMTiles 瓦片文件
│ └── icons/ # 自定义图标
├── data/ # 原始数据
│ └── relics.geojson # 文物 GeoJSON 数据
└── types/ # TypeScript 类型定义
└── relic.ts
```
## 搭建步骤
### 阶段 1: 项目初始化
```bash
# 创建 Next.js 项目
npx create-next-app@latest . --typescript --tailwind --app --no-src-dir --import-alias "@/*"
# 安装核心依赖
npm install maplibre-gl pmtiles
# 安装 UI 组件
npm install @radix-ui/react-dialog @radix-ui/react-select @radix-ui/react-slider
npm install lucide-react
# 安装国际化
npm install next-intl
# 安装开发工具
npm install -D @types/geojson
```
### 阶段 2: 国际化配置
**2.1 创建翻译文件**
`messages/zh.json`:
```json
{
"nav": {
"title": "中国文物全球分布",
"search": "搜索文物或博物馆"
},
"map": {
"layers": "图层",
"filters": "筛选器",
"reset": "重置"
},
"dynasty": {
"shang": "商周",
"qin": "秦汉",
"tang": "隋唐",
"song": "宋元",
"ming": "明清"
},
"category": {
"bronze": "青铜器",
"porcelain": "瓷器",
"painting": "书画",
"jade": "玉器"
}
}
```
`messages/en.json`:
```json
{
"nav": {
"title": "Chinese Cultural Relics Worldwide",
"search": "Search relics or museums"
},
"map": {
"layers": "Layers",
"filters": "Filters",
"reset": "Reset"
},
"dynasty": {
"shang": "Shang-Zhou",
"qin": "Qin-Han",
"tang": "Sui-Tang",
"song": "Song-Yuan",
"ming": "Ming-Qing"
},
"category": {
"bronze": "Bronze",
"porcelain": "Porcelain",
"painting": "Painting",
"jade": "Jade"
}
}
```
**2.2 配置 next-intl**
`i18n.ts`:
```typescript
import { getRequestConfig } from 'next-intl/server';
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`./messages/${locale}.json`)).default
}));
```
`middleware.ts`:
```typescript
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['zh', 'en'],
defaultLocale: 'zh',
localeDetection: true
});
export const config = {
matcher: ['/', '/(zh|en)/:path*']
};
```
### 阶段 3: 地图基础设施
**3.1 配置 MapLibre GL CSS**
-`app/[locale]/layout.tsx` 中引入 `maplibre-gl/dist/maplibre-gl.css`
**3.2 创建地图容器组件**
- `components/map/MapContainer.tsx`
- 初始化 MapLibre 地图实例
- 设置中国为初始中心点(北京:116.4074, 39.9042
- 使用 Carto 深色底图
**3.3 创建语言切换器**
```typescript
// components/LanguageSwitcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const toggleLanguage = () => {
const newLocale = locale === 'zh' ? 'en' : 'zh';
router.push(pathname.replace(`/${locale}`, `/${newLocale}`));
};
return (
<button onClick={toggleLanguage}>
{locale === 'zh' ? 'EN' : '中文'}
</button>
);
}
```
**3.4 集成 PMTiles**
- `lib/pmtiles-loader.ts`
- 实现 PMTiles Protocol 注册
- 支持本地和远程瓦片加载
### 阶段 4: 数据层
**4.1 定义数据结构(支持双语)**
```typescript
// types/relic.ts
interface Relic {
id: string;
name: {
zh: string; // 中文名称
en: string; // 英文名称
};
dynasty: string; // 朝代(使用 key,如 "tang"
category: string; // 类别(使用 key,如 "bronze"
currentLocation: {
zh: string; // 中文地点
en: string; // 英文地点
};
museum: {
zh: string; // 中文机构名
en: string; // 英文机构名
};
coordinates: [number, number]; // 经纬度
year?: string; // 年代
description?: {
zh: string;
en: string;
};
imageUrl?: string; // 图片 URL
protectionLevel?: string; // 保护级别
}
```
**4.2 准备示例数据(双语)**
- 创建 `data/relics.geojson`
- 至少包含 10-20 个示例文物点位
- 覆盖英国、法国、美国、日本等主要国家
**4.3 生成 PMTiles**
```bash
# 安装 tippecanoemacOS
brew install tippecanoe
# 转换 GeoJSON → PMTiles
tippecanoe -o public/tiles/relics.pmtiles \
--minimum-zoom=0 \
--maximum-zoom=14 \
--drop-densest-as-needed \
--extend-zooms-if-still-dropping \
data/relics.geojson
```
### 阶段 5: 图层系统
**4.1 朝代图层**
- 根据朝代(商周、秦汉、隋唐、宋元、明清等)分层
- 不同朝代使用不同颜色标识
- 支持单独开关
**4.2 类别图层**
- 青铜器、瓷器、书画、玉器、石刻等
- 使用不同图标表示
**4.3 气泡样式**
- 根据文物重要性(保护级别)调整大小
- 聚类显示(缩小时自动聚合)
### 阶段 6: 交互功能
**5.1 侧边栏控制面板**
- 图层树形结构(类似 OpenGridWorks
- 拖拽排序
- 开关动画
**5.2 筛选器**
- 朝代范围滑块
- 类别多选
- 保护级别筛选
- 国家/地区筛选
**5.3 搜索功能**
- 文物名称模糊搜索
- 收藏机构搜索
- 搜索结果定位到地图
**5.4 详情弹窗**
- 点击文物标记显示详情卡片
- 展示图片、名称、年代、描述等
- 提供外部链接(如博物馆官网)
### 阶段 7: 视觉优化
**6.1 主题样式**
- 采用深色主题(类似 OpenGridWorks
- 定义 CSS 变量统一配色
**6.2 响应式设计**
- 桌面端:侧边栏固定
- 移动端:抽屉式侧边栏
**6.3 动画效果**
- 图层切换动画
- 标记 hover 效果
- 平滑缩放过渡
### 阶段 8: 高级功能(可选)
**7.1 时间轴**
- 按文物流失年代播放动画
- 展示流散历程
**7.2 统计面板**
- 总文物数量
- 分布国家统计
- 朝代占比图表
**7.3 分享功能**
- 生成带参数的分享链接
- 截图导出
**7.4 预设视图**
- "丝绸之路文物"
- "战争掠夺文物"
- "敦煌流散文物"
## 核心配置文件
### `next.config.js`
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
// 支持 PMTiles 二进制文件
config.module.rules.push({
test: /\.pmtiles$/,
type: 'asset/resource',
});
return config;
},
};
module.exports = nextConfig;
```
### `tailwind.config.ts`
```typescript
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: 'class',
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
map: {
bg: '#0a0e27',
panel: '#1a1e3a',
accent: '#3b82f6',
},
},
},
},
plugins: [],
};
export default config;
```
## 开发流程
1. **初始化项目** → 安装依赖
2. **搭建地图** → 验证 MapLibre 正常渲染
3. **加载数据** → 展示示例文物点位
4. **实现图层** → 按朝代/类别分层
5. **添加交互** → 点击、筛选、搜索
6. **优化样式** → 深色主题、响应式
7. **测试部署** → Vercel 一键部署
## 数据来源建议
- **公开数据集**: UNESCO、大英博物馆、卢浮宫等开放数据
- **研究报告**: 流失文物统计报告
- **众包贡献**: 社区上传补充
## 参考资源
- [MapLibre GL JS 文档](https://maplibre.org/maplibre-gl-js/docs/)
- [PMTiles 规范](https://github.com/protomaps/PMTiles)
- [OpenGridWorks](https://opengridworks.com) - 参考实现
- [GeoJSON 规范](https://geojson.org/)
## 许可证
MIT License - 开源友好
---
**预计开发时间**: 2-3 天(核心功能)
**扩展功能**: 根据需求迭代
+140
View File
@@ -0,0 +1,140 @@
@import "tailwindcss";
:root {
--map-bg: #06060c;
--map-panel: #0d0d18;
--map-panel-hover: #161624;
--map-accent: #3b82f6;
--map-border: #1e1e30;
--map-text: #e2e8f0;
--map-text-muted: #6b7280;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--map-bg);
color: var(--map-text);
}
* {
box-sizing: border-box;
}
/* MapLibre GL 自定义样式 */
.maplibregl-ctrl-bottom-left,
.maplibregl-ctrl-bottom-right,
.maplibregl-ctrl-top-right {
display: none;
}
.maplibregl-popup-content {
background-color: var(--map-panel);
color: var(--map-text);
border: 1px solid var(--map-border);
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3);
}
.maplibregl-popup-close-button {
color: var(--map-text);
font-size: 20px;
padding: 4px 8px;
}
.maplibregl-popup-close-button:hover {
background-color: var(--map-panel-hover);
color: var(--map-accent);
}
/* 文物信息弹窗:中国风深色玻璃卡片 */
.heritage-popup .maplibregl-popup-content {
background: rgba(18, 16, 22, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
border: 1px solid rgba(212, 175, 55, 0.25);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
padding: 16px;
overflow: hidden;
}
.heritage-popup .maplibregl-popup-content::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.6), rgba(220, 38, 38, 0.5), transparent);
opacity: 0.8;
}
.heritage-popup .maplibregl-popup-close-button {
color: #a89a88;
font-size: 18px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
top: 10px;
right: 10px;
transition: all 0.2s ease;
}
.heritage-popup .maplibregl-popup-close-button:hover {
background: rgba(255, 255, 255, 0.08);
color: #f5f0e8;
}
.heritage-popup .maplibregl-popup-tip {
border-top-color: rgba(18, 16, 22, 0.92);
border-bottom-color: rgba(18, 16, 22, 0.92);
}
.maplibregl-popup-anchor-top .maplibregl-popup-tip,
.maplibregl-popup-anchor-top-left .maplibregl-popup-tip,
.maplibregl-popup-anchor-top-right .maplibregl-popup-tip {
border-bottom-color: var(--map-panel);
}
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip,
.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip,
.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip {
border-top-color: var(--map-panel);
}
.maplibregl-popup-anchor-left .maplibregl-popup-tip {
border-right-color: var(--map-panel);
}
.maplibregl-popup-anchor-right .maplibregl-popup-tip {
border-left-color: var(--map-panel);
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--map-bg);
}
::-webkit-scrollbar-thumb {
background: var(--map-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--map-panel-hover);
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales } from '@/i18n';
import "./globals.css";
import 'maplibre-gl/dist/maplibre-gl.css';
export const metadata: Metadata = {
title: "Heritage Globe - 中国文物全球分布",
description: "Explore Chinese cultural relics worldwide on an interactive map",
};
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params: paramsPromise
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const params = await paramsPromise;
const locale = params.locale;
// 验证语言参数
if (!locales.includes(locale as any)) {
notFound();
}
// 设置请求的语言环境(启用静态渲染优化)
setRequestLocale(locale);
// 获取翻译消息
const messages = await getMessages();
return (
<html lang={locale}>
<body className="antialiased">
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { MapContainer } from '@/components/map/MapContainer';
export default function HomePage() {
return (
<main className="h-screen w-screen overflow-hidden">
<MapContainer />
</main>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Heritage Globe - 中国文物全球分布",
description: "Explore Chinese cultural relics worldwide on an interactive map",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return children;
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation';
export default function RootPage() {
// 重定向到默认语言
redirect('/zh');
}
+822
View File
@@ -0,0 +1,822 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import maplibregl from 'maplibre-gl';
import type { FeatureCollection } from 'geojson';
import { Navbar } from '@/components/ui/Navbar';
import { MapControls } from '@/components/map/MapControls';
import { LayerPanel, LayerState } from '@/components/sidebar/LayerPanel';
import { relicsData, dynastyColors, categoryIcons } from '@/data/relics';
// 朝代颜色映射
const colors = dynastyColors;
// 种类形状映射(使用 MapLibre 符号图层实现不同图标)
const categories = ['painting', 'sculpture', 'bronze', 'porcelain', 'jade', 'calligraphy', 'textile', 'gold', 'lacquer', 'ceramic'];
const dynasties = Object.keys(colors);
const defaultLayerState: LayerState = {
dynasties: Object.fromEntries(dynasties.map((d) => [d, true])),
categories: Object.fromEntries(categories.map((c) => [c, true])),
clusters: true,
labels: true,
};
export function MapContainer() {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const [layerState, setLayerState] = useState<LayerState>(defaultLayerState);
const [loaded, setLoaded] = useState(false);
const applyFilters = useCallback(() => {
if (!map.current || !loaded) return;
const visibleDynasties = dynasties.filter((d) => layerState.dynasties[d]);
const visibleCategories = categories.filter((c) => layerState.categories[c]);
categories.forEach((category) => {
const layerId = `relics-${category}`;
if (!map.current?.getLayer(layerId)) return;
const categoryVisible = visibleCategories.includes(category);
const visibility = categoryVisible ? 'visible' : 'none';
map.current.setLayoutProperty(layerId, 'visibility', visibility);
if (categoryVisible) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const filter: any = ['all', ['==', ['get', 'category'], category], ['!', ['has', 'point_count']]];
if (visibleDynasties.length > 0 && visibleDynasties.length < dynasties.length) {
filter.push(['match', ['get', 'dynasty'], visibleDynasties, true, false]);
}
map.current.setFilter(layerId, filter);
}
});
['clusters', 'cluster-count'].forEach((id) => {
if (!map.current?.getLayer(id)) return;
map.current.setLayoutProperty(id, 'visibility', layerState.clusters ? 'visible' : 'none');
});
}, [layerState, loaded]);
useEffect(() => {
applyFilters();
}, [applyFilters]);
useEffect(() => {
if (!mapContainer.current || map.current) return;
// 初始化地图(3D 地球模式 + 星空背景)
// 使用 CARTO dark-matter vector 样式(与 OpenGridWorks 相同)
map.current = new maplibregl.Map({
container: mapContainer.current,
style: 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json',
center: [30, 25],
zoom: 1.8,
pitch: 0,
minZoom: 0.5,
maxZoom: 18,
maxPitch: 85,
});
// 样式加载完成后设置 globe 投影和星空背景
map.current.on('style.load', () => {
if (!map.current) return;
// 设置地球投影
map.current.setProjection({ type: 'globe' });
// 设置星空背景(纯黑太空)
map.current.setSky({
'sky-color': '#06060c',
'sky-horizon-blend': 0.0,
'horizon-color': '#06060c',
'horizon-fog-blend': 0.0,
});
// 隐藏 CARTO 背景层,让星空 canvas 在太空区域透出
if (map.current.getLayer('background')) {
map.current.setLayoutProperty('background', 'visibility', 'none');
}
// 添加海洋填充层:覆盖全球的多边形,只在地球表面渲染
// 这样海洋不透明,而太空区域仍然透明显示星空
map.current.addSource('ocean-fill', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [[[-180, -85], [180, -85], [180, 85], [-180, 85], [-180, -85]]],
},
properties: {},
},
});
// 在所有现有图层之前插入海洋填充层
const firstLayerId = map.current.getStyle().layers[0]?.id;
map.current.addLayer({
id: 'ocean-fill',
type: 'fill',
source: 'ocean-fill',
paint: {
'fill-color': '#0a0e1a',
},
}, firstLayerId);
});
// 地图加载完成后添加数据
map.current.on('load', () => {
if (!map.current) return;
// 添加经纬网格线(graticule
const graticuleData = generateGraticule(30);
map.current.addSource('graticule', {
type: 'geojson',
data: graticuleData as any,
});
// 主要网格线(30°间隔)
map.current.addLayer({
id: 'graticule-main',
type: 'line',
source: 'graticule',
paint: {
'line-color': '#1e3a5f',
'line-width': 0.8,
'line-opacity': 0.5,
},
});
// 添加更细的网格线(10°间隔)
const fineGraticule = generateGraticule(10, 30);
map.current.addSource('graticule-fine', {
type: 'geojson',
data: fineGraticule as any,
});
map.current.addLayer({
id: 'graticule-fine',
type: 'line',
source: 'graticule-fine',
paint: {
'line-color': '#15294a',
'line-width': 0.4,
'line-opacity': 0.3,
},
layout: {
'visibility': 'visible',
},
});
// 网格标签
const graticuleLabels = generateGraticuleLabels(30);
map.current.addSource('graticule-labels', {
type: 'geojson',
data: graticuleLabels as any,
});
map.current.addLayer({
id: 'graticule-labels',
type: 'symbol',
source: 'graticule-labels',
layout: {
'text-field': ['get', 'label'],
'text-size': 10,
'text-anchor': 'center',
'text-allow-overlap': true,
'text-font': ['Open Sans Semibold', 'Arial Unicode MS Bold'],
},
paint: {
'text-color': '#3b6ea5',
'text-opacity': 0.6,
},
});
// 添加文物数据源
map.current.addSource('relics', {
type: 'geojson',
data: relicsData as any,
cluster: true,
clusterMaxZoom: 8,
clusterRadius: 60,
});
// 为每个种类添加单独的图层(不同形状)
categories.forEach((category) => {
// 非聚类点图层
map.current!.addLayer({
id: `relics-${category}`,
type: 'symbol',
source: 'relics',
filter: ['all',
['==', ['get', 'category'], category],
['!', ['has', 'point_count']]
],
layout: {
'icon-image': `relic-${category}`,
'icon-size': 1.2,
'icon-allow-overlap': true,
'visibility': 'visible',
},
paint: {
'icon-color': [
'case',
...Object.entries(colors).flatMap(([dyn, col]) => [
['==', ['get', 'dynasty'], dyn],
col
]),
'#888888'
],
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
});
});
// 添加聚类图层
map.current.addLayer({
id: 'clusters',
type: 'circle',
source: 'relics',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#4a5568',
5, '#2d3748',
10, '#1a202c',
],
'circle-radius': ['step', ['get', 'point_count'], 20, 5, 28, 10, 36],
'circle-opacity': 0.85,
'circle-stroke-width': 2,
'circle-stroke-color': '#a0aec0',
},
});
// 聚类数量
map.current.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'relics',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 14,
},
paint: {
'text-color': '#ffffff',
},
});
// 创建自定义图标
createRelicIcons(map.current!);
setLoaded(true);
// 点击聚类放大
map.current.on('click', 'clusters', (e) => {
if (!map.current) return;
const features = map.current.queryRenderedFeatures(e.point, { layers: ['clusters'] });
const clusterId = features[0].properties.cluster_id;
(map.current.getSource('relics') as maplibregl.GeoJSONSource)
.getClusterExpansionZoom(clusterId)
.then((zoom) => {
if (!map.current) return;
map.current.easeTo({
center: (features[0].geometry as any).coordinates,
zoom: zoom,
});
});
});
// 点击文物显示详情
map.current.on('click', (e) => {
if (!map.current) return;
const features = map.current.queryRenderedFeatures(e.point, {
layers: categories.map(c => `relics-${c}`)
});
if (features.length === 0) return;
const props = features[0].properties;
const coordinates = (features[0].geometry as any).coordinates.slice();
const name = JSON.parse(props.name);
const museum = JSON.parse(props.museum);
const currentLocation = JSON.parse(props.currentLocation);
const dynastyName = getDynastyName(props.dynasty);
const categoryName = getCategoryName(props.category);
const color = colors[props.dynasty] || '#888888';
const icon = categoryIcons[props.category] || '●';
const html = `
<div style="min-width: 260px; max-width: 320px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; color: #f5f0e8;">
<div style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 10px;">
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 50%; background: linear-gradient(135deg, ${color}22, ${color}44); border: 1px solid ${color}66; flex-shrink: 0;">
<span style="font-size: 20px; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.4));">${icon}</span>
</div>
<div style="flex: 1; min-width: 0;">
<h3 style="margin: 0 0 4px 0; font-size: 17px; font-weight: 600; color: #faf6ef; letter-spacing: 0.04em; line-height: 1.3;">${name.zh}</h3>
<p style="margin: 0; font-size: 12px; color: #b0a89a; font-style: italic; letter-spacing: 0.02em;">${name.en}</p>
</div>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px;">
<span style="background: ${color}33; color: ${color}; border: 1px solid ${color}66; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;">${dynastyName}</span>
<span style="background: rgba(251, 191, 36, 0.12); color: #fbbf24; border: 1px solid rgba(251, 191, 36, 0.35); padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;">${categoryName}</span>
</div>
<div style="border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 10px; font-size: 13px; color: #d9d0c3; line-height: 1.7;">
<p style="margin: 5px 0; display: flex; gap: 6px;">
<span style="color: #a89a88; flex-shrink: 0;">收藏地</span>
<span style="color: #f5f0e8;">${currentLocation.zh}</span>
</p>
<p style="margin: 5px 0; display: flex; gap: 6px;">
<span style="color: #a89a88; flex-shrink: 0;">机构</span>
<span style="color: #f5f0e8;">${museum.zh}</span>
</p>
${props.year ? `<p style="margin: 5px 0; display: flex; gap: 6px;"><span style="color: #a89a88; flex-shrink: 0;">年代</span><span style="color: #f5f0e8;">${props.year}</span></p>` : ''}
</div>
</div>
`;
new maplibregl.Popup({ offset: 28, closeButton: true, maxWidth: '340px', className: 'heritage-popup' })
.setLngLat(coordinates)
.setHTML(html)
.addTo(map.current);
});
// 鼠标样式
categories.forEach(cat => {
map.current!.on('mouseenter', `relics-${cat}`, () => {
if (map.current) map.current.getCanvas().style.cursor = 'pointer';
});
map.current!.on('mouseleave', `relics-${cat}`, () => {
if (map.current) map.current.getCanvas().style.cursor = '';
});
});
map.current.on('mouseenter', 'clusters', () => {
if (map.current) map.current.getCanvas().style.cursor = 'pointer';
});
map.current.on('mouseleave', 'clusters', () => {
if (map.current) map.current.getCanvas().style.cursor = '';
});
});
return () => {
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, []);
const handleZoomIn = () => map.current?.zoomIn();
const handleZoomOut = () => map.current?.zoomOut();
const handleLocate = () => {
if (!map.current) return;
navigator.geolocation?.getCurrentPosition(
(pos) => {
map.current?.flyTo({
center: [pos.coords.longitude, pos.coords.latitude],
zoom: 5,
});
},
() => {
map.current?.flyTo({ center: [105, 35], zoom: 4 });
}
);
};
const handleSpotlight = () => {
// Placeholder: could enable circle selection mode
if (!map.current) return;
map.current.getCanvas().style.cursor = 'crosshair';
setTimeout(() => {
if (map.current) map.current.getCanvas().style.cursor = '';
}, 3000);
};
const handleSearch = (query: string) => {
if (!map.current) return;
const features = (relicsData as any).features;
const match = features.find((f: any) => {
const name = f.properties.name;
return (
name.zh.includes(query) ||
name.en.toLowerCase().includes(query.toLowerCase()) ||
f.properties.museum.zh.includes(query) ||
f.properties.museum.en.toLowerCase().includes(query.toLowerCase())
);
});
if (match) {
map.current.flyTo({
center: match.geometry.coordinates,
zoom: 8,
});
}
};
const handleReset = () => setLayerState(defaultLayerState);
return (
<div className="relative w-full h-full">
<Starfield />
<Navbar />
<MapControls
onSearch={handleSearch}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onLocate={handleLocate}
onSpotlight={handleSpotlight}
/>
<LayerPanel state={layerState} onChange={setLayerState} onReset={handleReset} />
<div ref={mapContainer} className="w-full h-full relative z-0" />
</div>
);
}
// 动态星空背景组件
function Starfield() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationId: number;
let running = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let stars: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let shootingStars: any[] = [];
// 按颜色分组,减少 fillStyle 切换
const colorGroups = [
{ rgb: '255, 255, 255', indices: [] as number[] },
{ rgb: '200, 220, 255', indices: [] as number[] },
{ rgb: '255, 240, 200', indices: [] as number[] },
{ rgb: '220, 230, 255', indices: [] as number[] },
];
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const count = Math.floor((canvas.width * canvas.height) / 3500);
colorGroups.forEach(g => g.indices = []);
stars = Array.from({ length: count }, (_, i) => {
const ci = Math.floor(Math.random() * colorGroups.length);
colorGroups[ci].indices.push(i);
return {
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
r: Math.random() * 1.5 + 0.3,
baseOpacity: Math.random() * 0.7 + 0.3,
twinkleSpeed: Math.random() * 0.04 + 0.01,
twinklePhase: Math.random() * Math.PI * 2,
vx: (Math.random() - 0.5) * 0.03,
vy: (Math.random() - 0.5) * 0.03,
ci,
};
});
};
resize();
window.addEventListener('resize', resize);
// 标签页隐藏时暂停动画
const onVisibility = () => {
if (document.hidden) {
running = false;
if (animationId) cancelAnimationFrame(animationId);
} else if (!running) {
running = true;
render();
}
};
document.addEventListener('visibilitychange', onVisibility);
let frame = 0;
const render = () => {
if (!running) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 按颜色分组绘制星星,减少 fillStyle 切换
for (const group of colorGroups) {
ctx.fillStyle = `rgba(${group.rgb}, 1)`;
for (const i of group.indices) {
const star = stars[i];
star.twinklePhase += star.twinkleSpeed;
star.x += star.vx;
star.y += star.vy;
if (star.x < 0) star.x = canvas.width;
if (star.x > canvas.width) star.x = 0;
if (star.y < 0) star.y = canvas.height;
if (star.y > canvas.height) star.y = 0;
const alpha = star.baseOpacity * (0.2 + 0.8 * (0.5 + 0.5 * Math.sin(star.twinklePhase)));
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(star.x, star.y, star.r, 0, Math.PI * 2);
ctx.fill();
if (star.r > 1.0) {
ctx.globalAlpha = alpha * 0.15;
ctx.beginPath();
ctx.arc(star.x, star.y, star.r * 2.5, 0, Math.PI * 2);
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
// 随机生成流星
frame++;
if (frame % 200 === 0 && Math.random() < 0.7) {
const startX = Math.random() * canvas.width;
const startY = Math.random() * canvas.height * 0.5;
const angle = Math.PI / 4 + (Math.random() - 0.5) * 0.3;
const speed = 8 + Math.random() * 6;
shootingStars.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0,
maxLife: 40 + Math.random() * 20,
length: 80 + Math.random() * 60,
});
}
// 绘制流星
shootingStars = shootingStars.filter(s => s.life < s.maxLife);
for (const s of shootingStars) {
s.life++;
s.x += s.vx;
s.y += s.vy;
const progress = s.life / s.maxLife;
const alpha = progress < 0.3 ? progress / 0.3 : 1 - (progress - 0.3) / 0.7;
const tailX = s.x - s.vx / Math.hypot(s.vx, s.vy) * s.length;
const tailY = s.y - s.vy / Math.hypot(s.vx, s.vy) * s.length;
const gradient = ctx.createLinearGradient(s.x, s.y, tailX, tailY);
gradient.addColorStop(0, `rgba(255, 255, 255, ${alpha})`);
gradient.addColorStop(0.5, `rgba(200, 220, 255, ${alpha * 0.5})`);
gradient.addColorStop(1, 'rgba(200, 220, 255, 0)');
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(tailX, tailY);
ctx.strokeStyle = gradient;
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(s.x, s.y, 1.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.fill();
}
animationId = requestAnimationFrame(render);
};
render();
return () => {
running = false;
if (animationId) cancelAnimationFrame(animationId);
window.removeEventListener('resize', resize);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 z-0 pointer-events-none"
style={{ background: '#06060c', willChange: 'transform' }}
/>
);
}
// 生成经纬网格线 GeoJSON
function generateGraticule(interval: number, skipMultiples?: number): FeatureCollection {
const features: any[] = [];
const skip = skipMultiples || 0;
// 经线 (meridians)
for (let lon = -180; lon <= 180; lon += interval) {
if (skip > 0 && lon % skip === 0) continue;
const coords: [number, number][] = [];
for (let lat = -85; lat <= 85; lat += 5) {
coords.push([lon, lat]);
}
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
properties: { type: 'meridian', value: lon },
});
}
// 纬线 (parallels)
for (let lat = -80; lat <= 80; lat += interval) {
if (skip > 0 && lat % skip === 0) continue;
const coords: [number, number][] = [];
for (let lon = -180; lon <= 180; lon += 5) {
coords.push([lon, lat]);
}
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
properties: { type: 'parallel', value: lat },
});
}
return { type: 'FeatureCollection', features };
}
// 生成网格标签 GeoJSON
function generateGraticuleLabels(interval: number): FeatureCollection {
const features: any[] = [];
// 经度标签(赤道附近)
for (let lon = -180; lon <= 180; lon += interval) {
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, 0] },
properties: {
label: lon === 0 ? '0°' : lon > 0 ? `${lon}°E` : `${Math.abs(lon)}°W`,
},
});
}
// 纬度标签(本初子午线附近)
for (let lat = -60; lat <= 60; lat += interval) {
if (lat === 0) continue;
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [0, lat] },
properties: {
label: lat > 0 ? `${lat}°N` : `${Math.abs(lat)}°S`,
},
});
}
return { type: 'FeatureCollection', features };
}
// 种类名称映射
function getCategoryName(category: string): string {
const names: Record<string, string> = {
painting: '书画', sculpture: '雕塑', bronze: '青铜', porcelain: '瓷器',
jade: '玉器', calligraphy: '书法', textile: '织物', gold: '金银',
lacquer: '漆器', ceramic: '陶器'
};
return names[category] || category;
}
// 朝代名称映射
function getDynastyName(dynasty: string): string {
const names: Record<string, string> = {
shang: '商', zhou: '周', qin: '秦', han: '汉',
tang: '唐', song: '宋', yuan: '元', ming: '明', qing: '清'
};
return names[dynasty] || dynasty;
}
// 创建自定义图标
function createRelicIcons(map: maplibregl.Map) {
const size = 40;
// 为每个种类创建不同形状的图标
categories.forEach(category => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
switch (category) {
case 'painting': // 方块
ctx.fillRect(8, 8, 24, 24);
ctx.strokeRect(8, 8, 24, 24);
break;
case 'sculpture': // 三角
ctx.beginPath();
ctx.moveTo(20, 6);
ctx.lineTo(34, 34);
ctx.lineTo(6, 34);
ctx.closePath();
ctx.fill();
ctx.stroke();
break;
case 'bronze': // 菱形
ctx.beginPath();
ctx.moveTo(20, 4);
ctx.lineTo(36, 20);
ctx.lineTo(20, 36);
ctx.lineTo(4, 20);
ctx.closePath();
ctx.fill();
ctx.stroke();
break;
case 'porcelain': // 圆点
ctx.beginPath();
ctx.arc(20, 20, 14, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
break;
case 'jade': // 六边形
drawHexagon(ctx, 20, 20, 14);
ctx.fill();
ctx.stroke();
break;
case 'calligraphy': // 星形
drawStar(ctx, 20, 20, 5, 14, 7);
ctx.fill();
ctx.stroke();
break;
case 'textile': // 菱星
drawDiamondStar(ctx, 20, 20, 12);
ctx.fill();
ctx.stroke();
break;
case 'gold': // 八边
drawOctagon(ctx, 20, 20, 14);
ctx.fill();
ctx.stroke();
break;
case 'lacquer': // 双菱
drawDoubleDiamond(ctx, 20, 20, 12);
ctx.fill();
ctx.stroke();
break;
case 'ceramic': // 空心圆
ctx.beginPath();
ctx.arc(20, 20, 14, 0, Math.PI * 2);
ctx.stroke();
break;
}
const imageData = ctx.getImageData(0, 0, size, size);
map.addImage(`relic-${category}`, { width: size, height: size, data: imageData.data as any });
});
}
function drawHexagon(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 2;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
}
function drawStar(ctx: CanvasRenderingContext2D, cx: number, cy: number, points: number, outer: number, inner: number) {
ctx.beginPath();
for (let i = 0; i < points * 2; i++) {
const r = i % 2 === 0 ? outer : inner;
const angle = (Math.PI / points) * i - Math.PI / 2;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
}
function drawDiamondStar(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.moveTo(cx, cy - r * 0.6);
ctx.lineTo(cx + r * 0.6, cy);
ctx.lineTo(cx, cy + r * 0.6);
ctx.lineTo(cx - r * 0.6, cy);
ctx.closePath();
}
function drawOctagon(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
ctx.beginPath();
for (let i = 0; i < 8; i++) {
const angle = (Math.PI / 4) * i - Math.PI / 8;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
}
function drawDoubleDiamond(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r * 0.7, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r * 0.7, cy);
ctx.closePath();
ctx.strokeRect(cx - r * 0.5, cy - r * 0.5, r, r);
}
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Search, Target, MapPin, Plus, Minus, X } from 'lucide-react';
interface MapControlsProps {
onSearch?: (query: string) => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onLocate?: () => void;
onSpotlight?: () => void;
}
export function MapControls({
onSearch,
onZoomIn,
onZoomOut,
onLocate,
onSpotlight,
}: MapControlsProps) {
const t = useTranslations('map');
const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState('');
const submitSearch = () => {
if (onSearch && query.trim()) {
onSearch(query.trim());
}
};
return (
<div className="absolute top-16 left-4 z-10 flex flex-col gap-2">
{/* Search */}
<div className="flex flex-col gap-2">
<button
onClick={() => setSearchOpen(!searchOpen)}
className="w-9 h-9 rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors shadow-lg"
aria-label="Open search"
>
{searchOpen ? <X className="w-4 h-4" /> : <Search className="w-4 h-4" />}
</button>
{searchOpen && (
<div className="flex items-center gap-1 p-1 rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] shadow-lg">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submitSearch()}
placeholder={t('search')}
className="w-48 px-2 py-1.5 bg-transparent text-sm text-[var(--map-text)] placeholder:text-[var(--map-text-muted)] focus:outline-none"
autoFocus
/>
<button
onClick={submitSearch}
className="w-7 h-7 rounded-md bg-[var(--map-panel)] hover:bg-[var(--map-panel-hover)] flex items-center justify-center text-[var(--map-text-muted)] hover:text-white transition-colors"
>
<Search className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Spotlight selection */}
<button
onClick={onSpotlight}
className="w-9 h-9 rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors shadow-lg"
aria-label="Enable spotlight selection"
>
<Target className="w-4 h-4" />
</button>
{/* Zoom to my location */}
<button
onClick={onLocate}
className="w-9 h-9 rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors shadow-lg"
aria-label="Zoom to my location"
>
<MapPin className="w-4 h-4" />
</button>
{/* Zoom in/out */}
<div className="flex flex-col rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] shadow-lg overflow-hidden">
<button
onClick={onZoomIn}
className="w-9 h-9 flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors"
aria-label={t('zoomIn')}
>
<Plus className="w-4 h-4" />
</button>
<div className="h-px bg-[var(--map-border)]" />
<button
onClick={onZoomOut}
className="w-9 h-9 flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors"
aria-label={t('zoomOut')}
>
<Minus className="w-4 h-4" />
</button>
</div>
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import {
Settings,
RotateCcw,
Bookmark,
Film,
Share2,
Camera,
MessageSquare,
LayoutGrid,
ChevronDown,
ChevronUp,
Eye,
EyeOff,
} from 'lucide-react';
import { dynastyColors, categoryIcons } from '@/data/relics';
export interface LayerState {
dynasties: Record<string, boolean>;
categories: Record<string, boolean>;
clusters: boolean;
labels: boolean;
}
interface LayerPanelProps {
state: LayerState;
onChange: (state: LayerState) => void;
onReset: () => void;
}
export function LayerPanel({ state, onChange, onReset }: LayerPanelProps) {
const t = useTranslations();
const [dynastyOpen, setDynastyOpen] = useState(true);
const [categoryOpen, setCategoryOpen] = useState(true);
const [displayOpen, setDisplayOpen] = useState(false);
const allDynastiesVisible = Object.values(state.dynasties).every(Boolean);
const allCategoriesVisible = Object.values(state.categories).every(Boolean);
const toggleDynasty = (dyn: string) => {
onChange({
...state,
dynasties: { ...state.dynasties, [dyn]: !state.dynasties[dyn] },
});
};
const toggleAllDynasties = () => {
const next = !allDynastiesVisible;
onChange({
...state,
dynasties: Object.fromEntries(Object.keys(state.dynasties).map((k) => [k, next])),
});
};
const toggleCategory = (cat: string) => {
onChange({
...state,
categories: { ...state.categories, [cat]: !state.categories[cat] },
});
};
const toggleAllCategories = () => {
const next = !allCategoriesVisible;
onChange({
...state,
categories: Object.fromEntries(Object.keys(state.categories).map((k) => [k, next])),
});
};
const dynastyName = (dyn: string) => {
try {
return t(`dynasty.${dyn}`);
} catch {
return dyn;
}
};
const categoryName = (cat: string) => {
try {
return t(`category.${cat}`);
} catch {
return cat;
}
};
return (
<div className="absolute top-16 right-4 bottom-4 z-10 w-72 flex flex-col gap-2 pointer-events-none">
{/* Main panel */}
<div className="bg-[#06060c]/95 backdrop-blur-md border border-[var(--map-border)] rounded-xl shadow-2xl overflow-hidden flex flex-col pointer-events-auto">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--map-border)]">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded bg-blue-600/20 text-blue-400 border border-blue-600/30">
Beta
</span>
<h2 className="text-sm font-semibold text-[var(--map-text)]">{t('sidebar.operatingRelics')}</h2>
</div>
<button
onClick={() => setDisplayOpen(!displayOpen)}
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white/10 text-[var(--map-text-muted)] hover:text-white transition-colors"
aria-label="Display settings"
>
<Settings className="w-4 h-4" />
</button>
</div>
{/* Display settings dropdown */}
{displayOpen && (
<div className="px-3 py-2 border-b border-[var(--map-border)] bg-white/5">
<p className="text-xs text-[var(--map-text-muted)] leading-relaxed">
{t('sidebar.displayHint')}
</p>
</div>
)}
{/* Scrollable sections */}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{/* Dynasty section */}
<div className="rounded-lg border border-[var(--map-border)] overflow-hidden">
<div className="w-full flex items-center justify-between px-3 py-2 bg-white/5">
<div className="flex items-center gap-2">
<button
onClick={toggleAllDynasties}
className={`w-6 h-6 rounded flex items-center justify-center text-xs transition-colors ${
allDynastiesVisible ? 'bg-blue-600 text-white' : 'bg-[var(--map-panel)] text-[var(--map-text-muted)]'
}`}
aria-label={allDynastiesVisible ? 'Hide all dynasties' : 'Show all dynasties'}
>
{allDynastiesVisible ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
</button>
<span className="text-sm font-medium text-[var(--map-text)]">{t('filter.dynasty')}</span>
</div>
<button
onClick={() => setDynastyOpen(!dynastyOpen)}
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white/10 transition-colors"
aria-label={dynastyOpen ? 'Collapse dynasty section' : 'Expand dynasty section'}
>
{dynastyOpen ? <ChevronUp className="w-4 h-4 text-[var(--map-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--map-text-muted)]" />}
</button>
</div>
{dynastyOpen && (
<div className="px-2 py-2 grid grid-cols-2 gap-1">
{Object.entries(dynastyColors).map(([dyn, color]) => (
<button
key={dyn}
onClick={() => toggleDynasty(dyn)}
className={`flex items-center gap-2 px-2 py-1.5 rounded-md text-xs transition-colors ${
state.dynasties[dyn] ? 'bg-white/10 text-white' : 'text-[var(--map-text-muted)] hover:bg-white/5'
}`}
>
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: color }} />
<span className="truncate">{dynastyName(dyn)}</span>
</button>
))}
</div>
)}
</div>
{/* Category section */}
<div className="rounded-lg border border-[var(--map-border)] overflow-hidden">
<div className="w-full flex items-center justify-between px-3 py-2 bg-white/5">
<div className="flex items-center gap-2">
<button
onClick={toggleAllCategories}
className={`w-6 h-6 rounded flex items-center justify-center text-xs transition-colors ${
allCategoriesVisible ? 'bg-blue-600 text-white' : 'bg-[var(--map-panel)] text-[var(--map-text-muted)]'
}`}
aria-label={allCategoriesVisible ? 'Hide all categories' : 'Show all categories'}
>
{allCategoriesVisible ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
</button>
<span className="text-sm font-medium text-[var(--map-text)]">{t('filter.category')}</span>
</div>
<button
onClick={() => setCategoryOpen(!categoryOpen)}
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white/10 transition-colors"
aria-label={categoryOpen ? 'Collapse category section' : 'Expand category section'}
>
{categoryOpen ? <ChevronUp className="w-4 h-4 text-[var(--map-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--map-text-muted)]" />}
</button>
</div>
{categoryOpen && (
<div className="px-2 py-2 grid grid-cols-2 gap-1">
{Object.entries(categoryIcons).map(([cat, icon]) => (
<button
key={cat}
onClick={() => toggleCategory(cat)}
className={`flex items-center gap-2 px-2 py-1.5 rounded-md text-xs transition-colors ${
state.categories[cat] ? 'bg-white/10 text-white' : 'text-[var(--map-text-muted)] hover:bg-white/5'
}`}
>
<span className="w-2.5 h-2.5 flex items-center justify-center text-[10px]">{icon}</span>
<span className="truncate">{categoryName(cat)}</span>
</button>
))}
</div>
)}
</div>
{/* Clusters toggle */}
<button
onClick={() => onChange({ ...state, clusters: !state.clusters })}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg border border-[var(--map-border)] transition-colors ${
state.clusters ? 'bg-white/10 text-white' : 'bg-white/5 text-[var(--map-text-muted)] hover:bg-white/10'
}`}
>
<span className="text-sm font-medium">{t('sidebar.clustering')}</span>
<span className="text-xs">{state.clusters ? t('sidebar.on') : t('sidebar.off')}</span>
</button>
{/* Labels toggle */}
<button
onClick={() => onChange({ ...state, labels: !state.labels })}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg border border-[var(--map-border)] transition-colors ${
state.labels ? 'bg-white/10 text-white' : 'bg-white/5 text-[var(--map-text-muted)] hover:bg-white/10'
}`}
>
<span className="text-sm font-medium">{t('sidebar.labels')}</span>
<span className="text-xs">{state.labels ? t('sidebar.on') : t('sidebar.off')}</span>
</button>
</div>
{/* Bottom actions */}
<div className="border-t border-[var(--map-border)] p-2 grid grid-cols-2 gap-1.5">
<ActionButton icon={<RotateCcw className="w-3.5 h-3.5" />} label={t('map.reset')} onClick={onReset} />
<ActionButton icon={<Bookmark className="w-3.5 h-3.5" />} label={t('sidebar.savedViews')} />
<ActionButton icon={<Film className="w-3.5 h-3.5" />} label={t('sidebar.cinematic')} />
<ActionButton icon={<Share2 className="w-3.5 h-3.5" />} label={t('sidebar.share')} />
<ActionButton icon={<Camera className="w-3.5 h-3.5" />} label={t('sidebar.screenshot')} />
<ActionButton icon={<MessageSquare className="w-3.5 h-3.5" />} label={t('sidebar.feedback')} />
</div>
</div>
{/* Cards panel toggle */}
<button className="self-end flex items-center gap-2 px-3 py-2 rounded-lg bg-[#0b0f1e]/90 backdrop-blur-md border border-[var(--map-border)] text-sm text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors pointer-events-auto">
<LayoutGrid className="w-4 h-4" />
<span>{t('sidebar.cards')}</span>
</button>
</div>
);
}
function ActionButton({
icon,
label,
onClick,
disabled,
}: {
icon: React.ReactNode;
label: string;
onClick?: () => void;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md bg-[var(--map-panel)] hover:bg-[var(--map-panel-hover)] border border-[var(--map-border)] text-xs text-[var(--map-text-muted)] hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{icon}
<span className="truncate">{label}</span>
</button>
);
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
import { Globe } from 'lucide-react';
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const toggleLanguage = () => {
const newLocale = locale === 'zh' ? 'en' : 'zh';
// 替换 URL 中的语言前缀
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
router.push(newPath);
};
return (
<button
onClick={toggleLanguage}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-sm font-medium text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors"
aria-label="Switch language"
>
<Globe className="w-4 h-4" />
<span>{locale === 'zh' ? 'EN' : '中文'}</span>
</button>
);
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { Menu, X, Globe2 } from 'lucide-react';
import { LanguageSwitcher } from '@/components/ui/LanguageSwitcher';
export function Navbar() {
const t = useTranslations('nav');
const [menuOpen, setMenuOpen] = useState(false);
return (
<nav className="absolute top-0 left-0 right-0 z-20 h-14 bg-[#06060c]/90 backdrop-blur-md border-b border-[var(--map-border)]">
<div className="flex items-center justify-between h-full px-4">
{/* Left: hamburger + brand */}
<div className="flex items-center gap-3">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-8 h-8 flex items-center justify-center rounded-md hover:bg-white/10 transition-colors"
aria-label={menuOpen ? 'Close navigation' : 'Open navigation'}
>
{menuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
<Link href="/" className="flex items-center gap-2 text-[var(--map-text)] hover:text-white transition-colors">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-amber-500 to-red-600 flex items-center justify-center">
<Globe2 className="w-4 h-4 text-white" />
</div>
<span className="font-semibold text-sm tracking-wide hidden sm:inline">{t('title')}</span>
</Link>
</div>
{/* Center nav link (like Plants link in reference) */}
<div className="hidden md:flex items-center gap-1">
<Link
href="/"
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors"
>
<Globe2 className="w-4 h-4" />
<span>{t('mapLink')}</span>
</Link>
</div>
{/* Right: language + sign in */}
<div className="flex items-center gap-2">
<LanguageSwitcher />
<button className="hidden sm:flex items-center px-3 py-1.5 rounded-md text-sm font-medium text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors">
{t('signIn')}
</button>
</div>
</div>
{/* Expandable nav links */}
{menuOpen && (
<div className="absolute top-14 left-0 right-0 bg-[#0b0f1e]/95 border-b border-[var(--map-border)] px-4 py-3 flex flex-col gap-1">
<Link href="/" className="text-sm text-[var(--map-text-muted)] hover:text-white py-1.5 px-2 rounded-md hover:bg-white/10 transition-colors">
{t('home')}
</Link>
<Link href="/" className="text-sm text-[var(--map-text-muted)] hover:text-white py-1.5 px-2 rounded-md hover:bg-white/10 transition-colors">
{t('about')}
</Link>
<Link href="/" className="text-sm text-[var(--map-text-muted)] hover:text-white py-1.5 px-2 rounded-md hover:bg-white/10 transition-colors">
{t('privacy')}
</Link>
<Link href="/" className="text-sm text-[var(--map-text-muted)] hover:text-white py-1.5 px-2 rounded-md hover:bg-white/10 transition-colors">
{t('terms')}
</Link>
</div>
)}
</nav>
);
}
+306
View File
@@ -0,0 +1,306 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-0.1276, 51.5194]
},
"properties": {
"id": "relic-001",
"name": {
"zh": "女史箴图",
"en": "Admonitions Scroll"
},
"dynasty": "tang",
"category": "painting",
"currentLocation": {
"zh": "英国伦敦",
"en": "London, UK"
},
"museum": {
"zh": "大英博物馆",
"en": "British Museum"
},
"year": "618-907",
"description": {
"zh": "东晋顾恺之绘制的绢本设色画,现存最早的中国卷轴画之一。",
"en": "A silk painting by Gu Kaizhi from the Eastern Jin dynasty, one of the earliest surviving Chinese scroll paintings."
},
"protectionLevel": "national",
"imageUrl": "/images/relics/admonitions-scroll.jpg"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [2.3364, 48.8606]
},
"properties": {
"id": "relic-002",
"name": {
"zh": "圆明园兽首",
"en": "Yuanmingyuan Bronze Heads"
},
"dynasty": "qin",
"category": "bronze",
"currentLocation": {
"zh": "法国巴黎",
"en": "Paris, France"
},
"museum": {
"zh": "吉美国立亚洲艺术博物馆",
"en": "Musée Guimet"
},
"year": "1750-1760",
"description": {
"zh": "圆明园海晏堂十二生肖兽首之一,清代宫廷艺术杰作。",
"en": "One of the twelve zodiac bronze heads from Yuanmingyuan's Haiyantang, a masterpiece of Qing court art."
},
"protectionLevel": "national"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-73.9632, 40.7794]
},
"properties": {
"id": "relic-003",
"name": {
"zh": "昭陵六骏",
"en": "Six Horses of Zhaoling"
},
"dynasty": "tang",
"category": "sculpture",
"currentLocation": {
"zh": "美国纽约",
"en": "New York, USA"
},
"museum": {
"zh": "宾夕法尼亚大学博物馆",
"en": "Penn Museum"
},
"year": "636-649",
"description": {
"zh": "唐太宗昭陵前的六匹战马浮雕,其中两件流失海外。",
"en": "Six stone relief carvings of war horses from Emperor Taizong's mausoleum, two of which are now overseas."
},
"protectionLevel": "national"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [139.7744, 35.7148]
},
"properties": {
"id": "relic-004",
"name": {
"zh": "青花瓷",
"en": "Blue and White Porcelain"
},
"dynasty": "ming",
"category": "porcelain",
"currentLocation": {
"zh": "日本东京",
"en": "Tokyo, Japan"
},
"museum": {
"zh": "东京国立博物馆",
"en": "Tokyo National Museum"
},
"year": "1368-1644",
"description": {
"zh": "明代景德镇官窑精品,代表中国瓷器制作的巅峰水平。",
"en": "A masterpiece from Ming dynasty Jingdezhen imperial kilns, representing the peak of Chinese porcelain craftsmanship."
},
"protectionLevel": "provincial"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-0.1276, 51.5194]
},
"properties": {
"id": "relic-005",
"name": {
"zh": "《永乐大典》",
"en": "Yongle Encyclopedia"
},
"dynasty": "ming",
"category": "calligraphy",
"currentLocation": {
"zh": "英国伦敦",
"en": "London, UK"
},
"museum": {
"zh": "大英图书馆",
"en": "British Library"
},
"year": "1403-1408",
"description": {
"zh": "明成祖永乐年间编纂的大型类书,现存部分流散世界各地。",
"en": "A massive encyclopedia compiled during the Yongle era, with surviving volumes scattered worldwide."
},
"protectionLevel": "national"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [2.3364, 48.8606]
},
"properties": {
"id": "relic-006",
"name": {
"zh": "和氏璧复刻",
"en": "Heshi Jade Replica"
},
"dynasty": "qin",
"category": "jade",
"currentLocation": {
"zh": "法国巴黎",
"en": "Paris, France"
},
"museum": {
"zh": "卢浮宫",
"en": "Louvre Museum"
},
"year": "770-221 BC",
"description": {
"zh": "春秋战国时期著名玉器的后世复刻品。",
"en": "A later replica of the famous jade from the Spring and Autumn period."
},
"protectionLevel": "municipal"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [12.4829, 41.8902]
},
"properties": {
"id": "relic-007",
"name": {
"zh": "商代青铜器",
"en": "Shang Dynasty Bronze Vessel"
},
"dynasty": "shang",
"category": "bronze",
"currentLocation": {
"zh": "意大利罗马",
"en": "Rome, Italy"
},
"museum": {
"zh": "东方博物馆",
"en": "Museum of Oriental Art"
},
"year": "1600-1046 BC",
"description": {
"zh": "商代晚期青铜礼器,反映了古代中国的礼乐文明。",
"en": "A late Shang dynasty bronze ritual vessel reflecting ancient Chinese ritual civilization."
},
"protectionLevel": "national"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [13.3777, 52.5163]
},
"properties": {
"id": "relic-008",
"name": {
"zh": "敦煌壁画临摹本",
"en": "Dunhuang Mural Copy"
},
"dynasty": "tang",
"category": "painting",
"currentLocation": {
"zh": "德国柏林",
"en": "Berlin, Germany"
},
"museum": {
"zh": "柏林亚洲艺术博物馆",
"en": "Museum of Asian Art Berlin"
},
"year": "618-907",
"description": {
"zh": "敦煌莫高窟壁画的临摹作品,记录了丝绸之路的辉煌。",
"en": "A copy of Dunhuang Mogao Caves murals, documenting the glory of the Silk Road."
},
"protectionLevel": "provincial"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [18.0686, 59.3293]
},
"properties": {
"id": "relic-009",
"name": {
"zh": "宋代瓷枕",
"en": "Song Dynasty Porcelain Pillow"
},
"dynasty": "song",
"category": "porcelain",
"currentLocation": {
"zh": "瑞典斯德哥尔摩",
"en": "Stockholm, Sweden"
},
"museum": {
"zh": "远东古物博物馆",
"en": "Museum of Far Eastern Antiquities"
},
"year": "960-1279",
"description": {
"zh": "宋代日常生活用品,展现了宋代瓷器的精湛工艺。",
"en": "A Song dynasty daily item showcasing exquisite porcelain craftsmanship."
},
"protectionLevel": "provincial"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-122.4683, 37.8199]
},
"properties": {
"id": "relic-010",
"name": {
"zh": "明代官窑瓷器",
"en": "Ming Imperial Porcelain"
},
"dynasty": "ming",
"category": "porcelain",
"currentLocation": {
"zh": "美国旧金山",
"en": "San Francisco, USA"
},
"museum": {
"zh": "亚洲艺术博物馆",
"en": "Asian Art Museum"
},
"year": "1368-1644",
"description": {
"zh": "明代官窑精品,彰显皇家气派与工艺水准。",
"en": "A Ming imperial kiln masterpiece displaying royal grandeur and craftsmanship."
},
"protectionLevel": "national"
}
}
]
}
+466
View File
@@ -0,0 +1,466 @@
import { FeatureCollection, Point } from 'geojson';
import { Relic } from '@/types/relic';
// 朝代颜色映射(按历史时间顺序)
export const dynastyColors: Record<string, string> = {
shang: '#CD7F32', // 商 - 青铜色
zhou: '#8B4513', // 周 - 棕褐
qin: '#FFD700', // 秦 - 金色
han: '#DC143C', // 汉 - 朱红
tang: '#FF6B6B', // 唐 - 桃红
song: '#4A90D9', // 宋 - 靛蓝
yuan: '#50C878', // 元 - 翠绿
ming: '#9370DB', // 明 - 紫罗兰
qing: '#FFB6C1', // 清 - 浅粉
};
// 文物种类图标(Unicode 符号)
export const categoryIcons: Record<string, string> = {
painting: '▪', // 书画 - 方块
sculpture: '▲', // 雕塑 - 三角
bronze: '◆', // 青铜器 - 菱形
porcelain: '●', // 瓷器 - 圆点
jade: '◇', // 玉器 - 钻石
calligraphy: '★', // 书法 - 星形
textile: '✦', // 织物 - 菱星
gold: '⬥', // 金银器 - 六边
lacquer: '◈', // 漆器 - 双菱
ceramic: '○', // 陶器 - 空心圆
};
// 种类中文名
export const categoryNames: Record<string, { zh: string; en: string }> = {
painting: { zh: '书画', en: 'Painting' },
sculpture: { zh: '雕塑', en: 'Sculpture' },
bronze: { zh: '青铜器', en: 'Bronze' },
porcelain: { zh: '瓷器', en: 'Porcelain' },
jade: { zh: '玉器', en: 'Jade' },
calligraphy: { zh: '书法', en: 'Calligraphy' },
textile: { zh: '织物', en: 'Textile' },
gold: { zh: '金银器', en: 'Gold/Silver' },
lacquer: { zh: '漆器', en: 'Lacquerware' },
ceramic: { zh: '陶器', en: 'Ceramics' },
};
export const relicsData: FeatureCollection<Point, Relic> = {
"type": "FeatureCollection",
"features": [
// ===== 商代青铜器 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [2.3522, 48.8566] },
"properties": {
"id": "relic-001", "dynasty": "shang", "category": "bronze",
"name": { "zh": "后母戊鼎", "en": "Ding of Simuwu" },
"currentLocation": { "zh": "法国巴黎", "en": "Paris, France" },
"museum": { "zh": "吉美博物馆", "en": "Musée Guimet" },
"year": "商代晚期", "description": { "zh": "商代青铜器代表", "en": "Shang bronze vessel" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [13.4101, 52.5244] },
"properties": {
"id": "relic-002", "dynasty": "shang", "category": "bronze",
"name": { "zh": "四羊方尊", "en": "Square Zun with Four Rams" },
"currentLocation": { "zh": "德国柏林", "en": "Berlin, Germany" },
"museum": { "zh": "柏林亚洲艺术博物馆", "en": "Museum of Asian Art" },
"year": "商代", "description": { "zh": "商代青铜礼器精品", "en": "Shang ritual bronze" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-0.0761, 51.5194] },
"properties": {
"id": "relic-003", "dynasty": "shang", "category": "bronze",
"name": { "zh": "皿天全方罍", "en": "Ritual Wine Vessel" },
"currentLocation": { "zh": "英国伦敦", "en": "London, UK" },
"museum": { "zh": "大英博物馆", "en": "British Museum" },
"year": "商代", "description": { "zh": "青铜罍代表", "en": "Bronze lei vessel" }
}
},
// ===== 春秋战国 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [139.6917, 35.6895] },
"properties": {
"id": "relic-004", "dynasty": "zhou", "category": "jade",
"name": { "zh": "王羲之《丧乱帖》", "en": "丧乱帖 Calligraphy" },
"currentLocation": { "zh": "日本东京", "en": "Tokyo, Japan" },
"museum": { "zh": "宫内厅", "en": "Imperial Household Agency" },
"year": "唐摹本", "description": { "zh": "书圣王羲之代表作唐摹本", "en": "Copy of Wang Xizhi's masterpiece" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [126.9780, 37.5665] },
"properties": {
"id": "relic-005", "dynasty": "zhou", "category": "jade",
"name": { "zh": "金镂玉衣", "en": "Jade burial suit" },
"currentLocation": { "zh": "韩国首尔", "en": "Seoul, South Korea" },
"museum": { "zh": "国立中央博物馆", "en": "National Museum of Korea" },
"year": "汉代", "description": { "zh": "汉代玉衣", "en": "Han dynasty jade burial suit" }
}
},
// ===== 秦代 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-74.0060, 40.7128] },
"properties": {
"id": "relic-006", "dynasty": "qin", "category": "sculpture",
"name": { "zh": "秦俑", "en": "Terracotta Warriors" },
"currentLocation": { "zh": "美国纽约", "en": "New York, USA" },
"museum": { "zh": "大都会艺术博物馆", "en": "Metropolitan Museum" },
"year": "秦代", "description": { "zh": "秦代陶俑", "en": "Qin dynasty terracotta" }
}
},
// ===== 汉代 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.1357, 44.4758] },
"properties": {
"id": "relic-007", "dynasty": "han", "category": "jade",
"name": { "zh": "金缕玉衣", "en": "Jade Burial Suit" },
"currentLocation": { "zh": "美国纽约", "en": "New York, USA" },
"museum": { "zh": "美国自然历史博物馆", "en": "American Museum of Natural History" },
"year": "西汉", "description": { "zh": "西汉中山靖王墓出土", "en": "Western Han jade suit" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [2.2989, 48.8530] },
"properties": {
"id": "relic-008", "dynasty": "han", "category": "textile",
"name": { "zh": "汉代织锦", "en": "Han Dynasty Brocade" },
"currentLocation": { "zh": "法国巴黎", "en": "Paris, France" },
"museum": { "zh": "国立吉美亚洲艺术博物馆", "en": "Musée Guimet" },
"year": "东汉", "description": { "zh": "汉代丝织品", "en": "Han silk textile" }
}
},
// ===== 唐代 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-0.1276, 51.5074] },
"properties": {
"id": "relic-009", "dynasty": "tang", "category": "painting",
"name": { "zh": "《女史箴图》", "en": "Admonitions of the Instructress" },
"currentLocation": { "zh": "英国伦敦", "en": "London, UK" },
"museum": { "zh": "大英博物馆", "en": "British Museum" },
"year": "东晋-唐摹本", "description": { "zh": "顾恺之绘制,唐摹本", "en": "Gu Kaizhi, Tang copy" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [139.7744, 35.7148] },
"properties": {
"id": "relic-010", "dynasty": "tang", "category": "painting",
"name": { "zh": "《祭侄文稿》", "en": "Manuscript for Nephew" },
"currentLocation": { "zh": "日本东京", "en": "Tokyo, Japan" },
"museum": { "zh": "东京国立博物馆", "en": "Tokyo National Museum" },
"year": "唐代", "description": { "zh": "颜真卿行书代表", "en": "Yan Zhenqing calligraphy" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-122.4194, 37.7749] },
"properties": {
"id": "relic-011", "dynasty": "tang", "category": "sculpture",
"name": { "zh": "昭陵六骏", "en": "Six Horses of Zhaoling" },
"currentLocation": { "zh": "美国旧金山", "en": "San Francisco, USA" },
"museum": { "zh": "亚洲艺术博物馆", "en": "Asian Art Museum" },
"year": "唐贞观十年", "description": { "zh": "唐太宗昭陵六骏之一", "en": "Emperor Taizong's war horse" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-74.0060, 40.7128] },
"properties": {
"id": "relic-012", "dynasty": "tang", "category": "sculpture",
"name": { "zh": "昭陵六骏·飒露紫", "en": "Horses of Zhaoling - Sa Lu Zi" },
"currentLocation": { "zh": "美国纽约", "en": "New York, USA" },
"museum": { "zh": "宾夕法尼亚大学博物馆", "en": "Penn Museum" },
"year": "636年", "description": { "zh": "昭陵六骏石刻之一", "en": "Zhaoling stone relief" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [13.3777, 52.5163] },
"properties": {
"id": "relic-013", "dynasty": "tang", "category": "painting",
"name": { "zh": "敦煌绢画", "en": "Dunhuang Silk Painting" },
"currentLocation": { "zh": "德国柏林", "en": "Berlin, Germany" },
"museum": { "zh": "柏林亚洲艺术博物馆", "en": "Museum of Asian Art" },
"year": "唐代", "description": { "zh": "敦煌莫高窟绢画", "en": "Dunhuang mural fragment" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.5738, 45.5068] },
"properties": {
"id": "relic-014", "dynasty": "tang", "category": "gold",
"name": { "zh": "鎏金舞马衔杯银壶", "en": "Gilded Silver Wine Vessel" },
"currentLocation": { "zh": "加拿大蒙特利尔", "en": "Montreal, Canada" },
"museum": { "zh": "蒙特利尔美术馆", "en": "Montreal Museum of Fine Arts" },
"year": "唐代", "description": { "zh": "唐代银器精品", "en": "Tang silver vessel" }
}
},
// ===== 宋代 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [18.4081, 59.3293] },
"properties": {
"id": "relic-015", "dynasty": "song", "category": "porcelain",
"name": { "zh": "汝窑瓷器", "en": "Ru Ware Vase" },
"currentLocation": { "zh": "瑞典斯德哥尔摩", "en": "Stockholm, Sweden" },
"museum": { "zh": "远东古物博物馆", "en": "Museum of Far Eastern Antiquities" },
"year": "北宋", "description": { "zh": "宋代五大名窑之一", "en": "One of Five Great Kilns" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [2.3522, 48.8566] },
"properties": {
"id": "relic-016", "dynasty": "song", "category": "painting",
"name": { "zh": "《雪山图》", "en": "Winter Landscape" },
"currentLocation": { "zh": "法国巴黎", "en": "Paris, France" },
"museum": { "zh": "卢浮宫", "en": "Louvre Museum" },
"year": "北宋", "description": { "zh": "宋代山水画", "en": "Song landscape painting" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-122.6789, 45.5152] },
"properties": {
"id": "relic-017", "dynasty": "song", "category": "porcelain",
"name": { "zh": "官窑葵口碗", "en": "Guan Ware Bowl" },
"currentLocation": { "zh": "美国波特兰", "en": "Portland, USA" },
"museum": { "zh": "波特兰艺术博物馆", "en": "Portland Art Museum" },
"year": "南宋", "description": { "zh": "宋代官窑瓷器", "en": "Southern Song Guan ware" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [126.9780, 37.5665] },
"properties": {
"id": "relic-018", "dynasty": "song", "category": "calligraphy",
"name": { "zh": "苏轼书法", "en": "Su Shi Calligraphy" },
"currentLocation": { "zh": "韩国首尔", "en": "Seoul, South Korea" },
"museum": { "zh": "国立中央博物馆", "en": "National Museum of Korea" },
"year": "北宋", "description": { "zh": "苏轼行书作品", "en": "Su Shi's calligraphy" }
}
},
// ===== 元代 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [13.2307, 52.4660] },
"properties": {
"id": "relic-019", "dynasty": "yuan", "category": "painting",
"name": { "zh": "《青卞隐居图》", "en": " Dwelling in the Qingbian Mountains" },
"currentLocation": { "zh": "德国柏林", "en": "Berlin, Germany" },
"museum": { "zh": "国家博物馆", "en": "Staatliche Museen" },
"year": "元代", "description": { "zh": "王蒙代表作", "en": "Wang Meng painting" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-0.1276, 51.5074] },
"properties": {
"id": "relic-020", "dynasty": "yuan", "category": "porcelain",
"name": { "zh": "青花瓷瓶", "en": "Blue and White Porcelain Vase" },
"currentLocation": { "zh": "英国伦敦", "en": "London, UK" },
"museum": { "zh": "大英博物馆", "en": "British Museum" },
"year": "元代", "description": { "zh": "元青花代表", "en": "Yuan blue and white" }
}
},
// ===== 明代 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [139.6917, 35.6895] },
"properties": {
"id": "relic-021", "dynasty": "ming", "category": "porcelain",
"name": { "zh": "景德镇官窑", "en": "Jingdezhen Imperial Porcelain" },
"currentLocation": { "zh": "日本东京", "en": "Tokyo, Japan" },
"museum": { "zh": "东京国立博物馆", "en": "Tokyo National Museum" },
"year": "明代", "description": { "zh": "明代御窑瓷器", "en": "Ming imperial porcelain" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [2.3522, 48.8566] },
"properties": {
"id": "relic-022", "dynasty": "ming", "category": "calligraphy",
"name": { "zh": "《永乐大典》", "en": "Yongle Encyclopedia" },
"currentLocation": { "zh": "法国巴黎", "en": "Paris, France" },
"museum": { "zh": "法国国家图书馆", "en": "Bibliothèque nationale" },
"year": "1402-1408", "description": { "zh": "明永乐大典残卷", "en": "Ming encyclopedia fragment" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-0.1276, 51.5074] },
"properties": {
"id": "relic-023", "dynasty": "ming", "category": "lacquer",
"name": { "zh": "雕漆盒", "en": "Carved Lacquer Box" },
"currentLocation": { "zh": "英国伦敦", "en": "London, UK" },
"museum": { "zh": "维多利亚与艾伯特博物馆", "en": "V&A Museum" },
"year": "明代", "description": { "zh": "明代剔红漆器", "en": "Ming carved lacquer" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [126.9780, 37.5665] },
"properties": {
"id": "relic-024", "dynasty": "ming", "category": "gold",
"name": { "zh": "金冠", "en": "Golden Crown" },
"currentLocation": { "zh": "韩国首尔", "en": "Seoul, South Korea" },
"museum": { "zh": "国立中央博物馆", "en": "National Museum of Korea" },
"year": "明代", "description": { "zh": "明代皇室金器", "en": "Ming imperial gold" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-122.4194, 37.7749] },
"properties": {
"id": "relic-025", "dynasty": "ming", "category": "porcelain",
"name": { "zh": "斗彩鸡缸杯", "en": "Doucai Chicken Cup" },
"currentLocation": { "zh": "美国旧金山", "en": "San Francisco, USA" },
"museum": { "zh": "亚洲艺术博物馆", "en": "Asian Art Museum" },
"year": "明成化", "description": { "zh": "成化斗彩精品", "en": "Chenghua doucai cup" }
}
},
// ===== 清代 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [2.3522, 48.8566] },
"properties": {
"id": "relic-026", "dynasty": "qing", "category": "porcelain",
"name": { "zh": "圆明园兽首", "en": "Yuanmingyuan Zodiac Heads" },
"currentLocation": { "zh": "法国巴黎", "en": "Paris, France" },
"museum": { "zh": "皮诺美术馆", "en": "Pinault Collection" },
"year": "清乾隆", "description": { "zh": "圆明园十二生肖铜首", "en": "Qing zodiac bronze" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [126.9780, 37.5665] },
"properties": {
"id": "relic-027", "dynasty": "qing", "category": "bronze",
"name": { "zh": "圆明园牛首", "en": "Yuanmingyuan Ox Head" },
"currentLocation": { "zh": "中国北京(已回归)", "en": "Beijing, China" },
"museum": { "zh": "中国国家博物馆", "en": "National Museum of China" },
"year": "清乾隆", "description": { "zh": "圆明园十二生肖之一", "en": "Qing zodiac bronze" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [139.6917, 35.6895] },
"properties": {
"id": "relic-028", "dynasty": "qing", "category": "jade",
"name": { "zh": "翡翠白菜", "en": "Jadeite Cabbage" },
"currentLocation": { "zh": "日本东京", "en": "Tokyo, Japan" },
"museum": { "zh": "东京国立博物馆", "en": "Tokyo National Museum" },
"year": "清代", "description": { "zh": "翠玉雕白菜摆件", "en": "Qing jade carving" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-0.1276, 51.5074] },
"properties": {
"id": "relic-029", "dynasty": "qing", "category": "textile",
"name": { "zh": "龙袍", "en": "Dragon Robe" },
"currentLocation": { "zh": "英国伦敦", "en": "London, UK" },
"museum": { "zh": "大英博物馆", "en": "British Museum" },
"year": "清代", "description": { "zh": "清代缂丝龙袍", "en": "Qing tapestry dragon robe" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.5738, 45.5068] },
"properties": {
"id": "relic-030", "dynasty": "qing", "category": "ceramic",
"name": { "zh": "珐琅彩瓶", "en": "Falangcai Enamel Vase" },
"currentLocation": { "zh": "加拿大蒙特利尔", "en": "Montreal, Canada" },
"museum": { "zh": "蒙特利尔美术馆", "en": "Montreal Museum of Fine Arts" },
"year": "清康熙", "description": { "zh": "康熙珐琅彩瓷", "en": "Kangxi falangcai" }
}
},
// ===== 敦煌文书 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [11.5761, 48.1372] },
"properties": {
"id": "relic-031", "dynasty": "tang", "category": "calligraphy",
"name": { "zh": "敦煌藏经洞文书", "en": "Dunhuang Manuscripts" },
"currentLocation": { "zh": "德国慕尼黑", "en": "Munich, Germany" },
"museum": { "zh": "巴伐利亚州立图书馆", "en": "Bavarian State Library" },
"year": "唐代", "description": { "zh": "敦煌藏经洞出土", "en": "Dunhuang cave manuscripts" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [2.2100, 41.3866] },
"properties": {
"id": "relic-032", "dynasty": "tang", "category": "textile",
"name": { "zh": "敦煌织物残片", "en": "Dunhuang Textile Fragment" },
"currentLocation": { "zh": "西班牙巴塞罗那", "en": "Barcelona, Spain" },
"museum": { "zh": "加泰罗尼亚艺术博物馆", "en": "Museu Nacional d'Art de Catalunya" },
"year": "唐代", "description": { "zh": "敦煌出土丝织品", "en": "Tang silk textile" }
}
},
// ===== 更多代表性文物 =====
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [2.1734, 41.3851] },
"properties": {
"id": "relic-033", "dynasty": "shang", "category": "bronze",
"name": { "zh": "虎食人卣", "en": "Tiger Devouring Man You" },
"currentLocation": { "zh": "法国巴黎", "en": "Paris, France" },
"museum": { "zh": "赛努奇博物馆", "en": "Musée Cernuschi" },
"year": "商代", "description": { "zh": "商代著名青铜酒器", "en": "Famous Shang bronze" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-0.1276, 51.5074] },
"properties": {
"id": "relic-034", "dynasty": "zhou", "category": "bronze",
"name": { "zh": "毛公鼎", "en": "Mao Gong Ding" },
"currentLocation": { "zh": "中国台北", "en": "Taipei, Taiwan" },
"museum": { "zh": "台北故宫博物院", "en": "National Palace Museum" },
"year": "西周晚期", "description": { "zh": "西周青铜器铭文最长", "en": "Longest bronze inscription" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [114.0579, 22.5431] },
"properties": {
"id": "relic-035", "dynasty": "qin", "category": "bronze",
"name": { "zh": "阳陵虎符", "en": "Tiger Tally of Yangling" },
"currentLocation": { "zh": "中国香港", "en": "Hong Kong" },
"museum": { "zh": "香港中文大学文物馆", "en": "Art Museum, CUHK" },
"year": "秦代", "description": { "zh": "秦代调兵信物", "en": "Qin military tally" }
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.9632, 40.7794] },
"properties": {
"id": "relic-036", "dynasty": "han", "category": "lacquer",
"name": { "zh": "漆羽觞", "en": "Lacquer Wine Cup" },
"currentLocation": { "zh": "美国纽约", "en": "New York, USA" },
"museum": { "zh": "大都会艺术博物馆", "en": "Metropolitan Museum" },
"year": "西汉", "description": { "zh": "汉代漆器精品", "en": "Han lacquerware" }
}
}
]
};
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+21
View File
@@ -0,0 +1,21 @@
import { getRequestConfig } from 'next-intl/server';
import { notFound } from 'next/navigation';
// 支持的语言列表
export const locales = ['zh', 'en'] as const;
export type Locale = (typeof locales)[number];
export default getRequestConfig(async ({ requestLocale }) => {
// 等待并获取语言参数
let locale = await requestLocale;
// 验证语言是否支持,不支持则使用默认语言
if (!locale || !locales.includes(locale as Locale)) {
locale = 'zh';
}
return {
locale,
messages: (await import(`./messages/${locale}.json`)).default,
};
});
+85
View File
@@ -0,0 +1,85 @@
{
"nav": {
"title": "Chinese Cultural Relics Worldwide",
"search": "Search relics or museums...",
"languageSwitch": "中文",
"mapLink": "Relic Map",
"signIn": "Sign in",
"home": "Home",
"about": "About",
"privacy": "Privacy",
"terms": "Terms"
},
"map": {
"layers": "Layers",
"filters": "Filters",
"reset": "Reset",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"myLocation": "Go to My Location"
},
"sidebar": {
"operatingRelics": "Relic Distribution",
"technology": "Category",
"sortByName": "Sort by Name",
"sortByImportance": "Sort by Importance",
"showAll": "Show All",
"hideAll": "Hide All",
"displayHint": "Toggle layers to control which relics appear on the map. Filter by dynasty and category.",
"clustering": "Clustering",
"labels": "Place Labels",
"on": "On",
"off": "Off",
"savedViews": "Saved Views",
"cinematic": "Cinematic",
"share": "Share Link",
"screenshot": "Screenshot",
"feedback": "Feedback",
"cards": "Cards"
},
"dynasty": {
"shang": "Shang",
"zhou": "Zhou",
"qin": "Qin",
"han": "Han",
"tang": "Tang",
"song": "Song",
"yuan": "Yuan",
"ming": "Ming",
"qing": "Qing"
},
"category": {
"bronze": "Bronze",
"porcelain": "Porcelain",
"painting": "Painting",
"jade": "Jade",
"sculpture": "Sculpture",
"calligraphy": "Calligraphy",
"textile": "Textile",
"gold": "Gold/Silver",
"lacquer": "Lacquerware",
"ceramic": "Ceramics"
},
"filter": {
"dynasty": "Dynasty Filter",
"category": "Category Filter",
"country": "Country Filter",
"protectionLevel": "Protection Level"
},
"detail": {
"name": "Name",
"dynasty": "Dynasty",
"category": "Category",
"year": "Year",
"currentLocation": "Current Location",
"museum": "Museum",
"description": "Description",
"protectionLevel": "Protection Level",
"viewMore": "View More"
},
"stats": {
"totalRelics": "Total Relics",
"countries": "Countries",
"museums": "Museums"
}
}
+85
View File
@@ -0,0 +1,85 @@
{
"nav": {
"title": "中国文物全球分布",
"search": "搜索文物或博物馆...",
"languageSwitch": "English",
"mapLink": "文物地图",
"signIn": "登录",
"home": "首页",
"about": "关于",
"privacy": "隐私",
"terms": "条款"
},
"map": {
"layers": "图层",
"filters": "筛选器",
"reset": "重置",
"zoomIn": "放大",
"zoomOut": "缩小",
"myLocation": "定位到我的位置"
},
"sidebar": {
"operatingRelics": "文物分布",
"technology": "类别",
"sortByName": "按名称排序",
"sortByImportance": "按重要性排序",
"showAll": "显示全部",
"hideAll": "隐藏全部",
"displayHint": "勾选图层以控制文物在地图上的显示。可按朝代与类别分别筛选。",
"clustering": "聚类显示",
"labels": "地点标签",
"on": "开启",
"off": "关闭",
"savedViews": "保存视图",
"cinematic": "电影视图",
"share": "分享链接",
"screenshot": "截图",
"feedback": "反馈",
"cards": "卡片面板"
},
"dynasty": {
"shang": "商",
"zhou": "周",
"qin": "秦",
"han": "汉",
"tang": "唐",
"song": "宋",
"yuan": "元",
"ming": "明",
"qing": "清"
},
"category": {
"bronze": "青铜器",
"porcelain": "瓷器",
"painting": "书画",
"jade": "玉器",
"sculpture": "雕塑",
"calligraphy": "书法",
"textile": "织物",
"gold": "金银器",
"lacquer": "漆器",
"ceramic": "陶器"
},
"filter": {
"dynasty": "朝代筛选",
"category": "类别筛选",
"country": "国家筛选",
"protectionLevel": "保护级别"
},
"detail": {
"name": "名称",
"dynasty": "朝代",
"category": "类别",
"year": "年代",
"currentLocation": "当前收藏地",
"museum": "收藏机构",
"description": "描述",
"protectionLevel": "保护级别",
"viewMore": "查看详情"
},
"stats": {
"totalRelics": "文物总数",
"countries": "分布国家",
"museums": "收藏机构"
}
}
+13
View File
@@ -0,0 +1,13 @@
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['zh', 'en'],
defaultLocale: 'zh',
localeDetection: true,
localePrefix: 'always',
});
export const config = {
// 匹配所有路径,除了 api、_next/static、_next/image、favicon.ico
matcher: ['/', '/(zh|en)/:path*', '/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
+13
View File
@@ -0,0 +1,13 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n.ts');
const nextConfig: NextConfig = {
// 指定正确的项目根目录
turbopack: {
root: '/Users/freedak/Documents/AIDashboard/heritage-globe',
},
};
export default withNextIntl(nextConfig);
+8570
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "heritage-globe",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slider": "^1.4.1",
"lucide-react": "^1.22.0",
"maplibre-gl": "^5.24.0",
"next": "16.2.9",
"next-intl": "^4.13.0",
"pmtiles": "^4.4.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/geojson": "^7946.0.16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+64
View File
@@ -0,0 +1,64 @@
import { Feature, Point } from 'geojson';
// 文物基础信息(支持双语)
export interface Relic {
id: string;
name: {
zh: string;
en: string;
};
dynasty: DynastyType;
category: CategoryType;
currentLocation: {
zh: string;
en: string;
};
museum: {
zh: string;
en: string;
};
coordinates?: [number, number]; // [longitude, latitude] (usually stored in geometry)
year?: string;
description?: {
zh: string;
en: string;
};
imageUrl?: string;
protectionLevel?: ProtectionLevel;
}
// 朝代类型
export type DynastyType = 'shang' | 'zhou' | 'qin' | 'han' | 'tang' | 'song' | 'yuan' | 'ming' | 'qing';
// 类别类型
export type CategoryType = 'bronze' | 'porcelain' | 'painting' | 'jade' | 'sculpture' | 'calligraphy' | 'textile' | 'gold' | 'lacquer' | 'ceramic';
// 保护级别
export type ProtectionLevel = 'national' | 'provincial' | 'municipal';
// GeoJSON Feature 类型
export type RelicFeature = Feature<Point, Relic>;
// 图层配置
export interface LayerConfig {
id: string;
name: {
zh: string;
en: string;
};
visible: boolean;
color: string;
filter?: {
dynasty?: DynastyType[];
category?: CategoryType[];
};
}
// 地图视图状态
export interface MapViewState {
longitude: number;
latitude: number;
zoom: number;
bearing?: number;
pitch?: number;
}