feat: Heritage Globe - 中国文物全球分布地图
- Next.js + MapLibre GL 3D 地球 - CARTO dark-matter vector 底图 - 动态星空背景(闪烁、漂移、流星) - 经纬网格线(graticule) - 文物数据图层与朝代/类别筛选 - 中国风信息弹窗 - 中英双语 i18n - LayerPanel + MapControls 浮动面板
This commit is contained in:
+41
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -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
@@ -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
|
||||
# 安装 tippecanoe(macOS)
|
||||
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 天(核心功能)
|
||||
**扩展功能**: 根据需求迭代
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function RootPage() {
|
||||
// 重定向到默认语言
|
||||
redirect('/zh');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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" }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "收藏机构"
|
||||
}
|
||||
}
|
||||
@@ -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).*)'],
|
||||
};
|
||||
@@ -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);
|
||||
Generated
+8570
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user