Update WBBR frontend template collection
This commit is contained in:
51
.gitignore
vendored
51
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# dependencies and local caches
|
# Dependencies and package-manager caches
|
||||||
/node_modules
|
/node_modules/
|
||||||
/.npm-cache/
|
/.npm-cache/
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
@@ -9,26 +9,49 @@
|
|||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
|
||||||
# tests and builds
|
# Build, framework and test output
|
||||||
/coverage
|
|
||||||
/.next/
|
/.next/
|
||||||
/.vinext/
|
/.vinext/
|
||||||
/out/
|
|
||||||
/dist/
|
/dist/
|
||||||
/.wrangler/
|
/out/
|
||||||
|
/coverage/
|
||||||
/outputs/
|
/outputs/
|
||||||
/work/
|
/work/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
# local files
|
# Cloudflare and deployment state
|
||||||
.DS_Store
|
/.wrangler/
|
||||||
|
/.vercel/
|
||||||
|
|
||||||
|
# Environment files and local secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
*.pem
|
*.pem
|
||||||
*.starter
|
|
||||||
*.pre-tsbuild
|
# Runtime and debug logs
|
||||||
|
*.log
|
||||||
|
*.log.*
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
.env*
|
|
||||||
.vercel
|
# Editors and operating systems
|
||||||
next-env.d.ts
|
/.idea/
|
||||||
|
/.vscode/*
|
||||||
|
!/.vscode/extensions.json
|
||||||
|
!/.vscode/settings.json
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Temporary local artifacts
|
||||||
|
*.starter
|
||||||
|
*.pre-tsbuild
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|||||||
214
README.md
214
README.md
@@ -1,79 +1,197 @@
|
|||||||
# OrbitFlow React 后台模板
|
# WBBR · AI 前端样式模板工程
|
||||||
|
|
||||||
一个无需后端即可运行的现代后台管理模板。项目基于 React 19、Next.js 与 vinext,所有演示业务数据来自本地 JSON;定时任务、审批流程和工作列表修改通过 `localStorage` 保存在当前浏览器。
|
WBBR 是一套用于在项目内为 AI 定义、展示和复用前端样式的 React 模板工程。它通过可直接运行的真实页面,向 AI 提供明确的布局、颜色、组件、交互和响应式参考,减少生成页面时的视觉偏差。仓库内的演示内容由本地 JSON 提供,无需后端即可作为 AI 编码的前端样式基准。
|
||||||
|
|
||||||
## 已包含
|
## 项目特点
|
||||||
|
|
||||||
- 深色多级侧边菜单与移动端抽屉导航
|
- 纯前端运行:无需数据库、接口服务或登录系统
|
||||||
- 标签、按钮、表单、状态反馈与菜单样式
|
- 模板集中导航:首页统一展示并搜索全部页面模板
|
||||||
- 图片文字列表和响应式卡片视图
|
- 本地模拟数据:业务数据集中保存在 `app/data` 目录
|
||||||
- 支持拖放的四列流程泳道
|
- 独立页面路由:每个模板可单独浏览、修改和复用
|
||||||
- 定时任务列表、启停开关和新建配置弹窗
|
- 完整交互演示:包含拖拽、筛选、编辑、高亮、预览和本地保存
|
||||||
- 类飞书的审批流程设计器
|
- 响应式布局:兼容桌面端、平板和移动端尺寸
|
||||||
- 项目、里程碑、任务、子任务多级工作表格
|
- 易于接入后端:保留现有组件,只需替换 JSON 数据读取方式
|
||||||
- 全局搜索、批量选择、筛选、Toast 等交互
|
- 面向 AI 协作:通过完整页面和组件实例明确项目期望的前端视觉语言
|
||||||
|
|
||||||
## 工作列表
|
## 在 AI 开发中的用途
|
||||||
|
|
||||||
打开 `/worklist`,或点击后台首页右下角“工作列表”入口。页面支持:
|
- 让 AI 优先复用本工程中的页面结构、组件形态和 CSS 规则
|
||||||
|
- 为新页面提供可定位的路由参考,而不是仅依赖文字描述样式
|
||||||
|
- 统一后台、内容页和可视化页面的视觉密度与交互方式
|
||||||
|
- 使用本地 JSON 快速验证 AI 生成的页面,不依赖后端开发进度
|
||||||
|
- 将确认后的新模板继续沉淀进主页导航,形成项目样式资产库
|
||||||
|
|
||||||
- 项目 → 里程碑 → 任务 → 子任务四级树形表格
|
## 模板导航
|
||||||
- 单级展开、全部展开与全部收起
|
|
||||||
|
| 路由 | 模板 | 主要功能 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/` | 模板中心 | 分类导航、模板搜索、精选模板入口 |
|
||||||
|
| `/admin` | 后台组件总览 | 多级菜单、标签、按钮、图文列表、定时任务 |
|
||||||
|
| `/approval` | 审批流程设计 | 类飞书流程图、节点添加、条件分支、节点配置、JSON 预览 |
|
||||||
|
| `/worklist` | 多级工作列表 | 项目、里程碑、任务和子任务树形表格 |
|
||||||
|
| `/kanban` | 泳道任务看板 | 卡片跨泳道拖拽、状态更新、任务卡片 |
|
||||||
|
| `/repeatable-form` | 可重复表单 | 动态添加表单组、图片列表、文件列表和本地保存 |
|
||||||
|
| `/simple-list` | 简单列表 | 搜索、标签筛选、分页和批量操作 |
|
||||||
|
| `/markdown` | Markdown 编辑器 | 文档编辑、实时预览和浏览器本地保存 |
|
||||||
|
| `/anime` | 动漫作品详情 | 作品资料、剧集、评论及衍生商品展示 |
|
||||||
|
| `/character` | 角色详情 | 角色标签、关系、档案与剧情轨迹 |
|
||||||
|
| `/image-host` | 响应式图床 | 瀑布流、图集、灰色蒙版、延时大图预览和详情页 |
|
||||||
|
| `/media-player` | 视频播放器 | 播放控制、进度、倍速、播放列表、画中画与全屏 |
|
||||||
|
| `/music-player` | 音乐播放器 | 同步歌词、可拖动歌单和播放模式 |
|
||||||
|
| `/calendar` | 时令事件日历 | 连续事件、日期高亮、水果与海鲜应季期 |
|
||||||
|
| `/produce-map` | 全球产物地图 | 国家和一级行政区高亮、河北省市级下钻、产物详情 |
|
||||||
|
| `/bigscreen` | 数据大屏 | 实时指标、经营数据和趋势图表展示 |
|
||||||
|
| `/stargraph` | 星空关系网络 | 节点添加、直线关联、悬停详情和 JSON 导入导出 |
|
||||||
|
| `/icons` | 网页图标库 | 图标分类、搜索、预览与代码复制 |
|
||||||
|
|
||||||
|
## 重点功能
|
||||||
|
|
||||||
|
### 审批流程设计器
|
||||||
|
|
||||||
|
- 审批、条件分支和抄送节点
|
||||||
|
- 点击或拖入方式添加节点
|
||||||
|
- 单人审批、或签和会签配置
|
||||||
|
- 审批人、表单权限及高级规则配置
|
||||||
|
- 流程校验、模拟预览和 JSON 查看
|
||||||
|
- 自动保存与浏览器本地发布状态
|
||||||
|
|
||||||
|
### 多级工作列表
|
||||||
|
|
||||||
|
- 项目 → 里程碑 → 任务 → 子任务四级结构
|
||||||
|
- 单项展开、全部展开和全部收起
|
||||||
- 关键词、状态和优先级组合筛选
|
- 关键词、状态和优先级组合筛选
|
||||||
- 多选、全选和悬浮批量操作栏
|
- 多选、全选及批量操作
|
||||||
- 负责人、状态、优先级、进度与计划日期
|
- 工作项详情与状态编辑
|
||||||
- 工作项详情侧栏及状态编辑
|
|
||||||
- 选择任意上级新建子任务
|
|
||||||
|
|
||||||
示例数据位于 `app/data/work-list.json`。
|
### 全球产物地图
|
||||||
|
|
||||||
## 审批流程设计器
|
- 中国、日本、美国、俄罗斯等国家和地区地图
|
||||||
|
- 一级行政区点击、悬停和高亮展示
|
||||||
|
- 美国本土与阿拉斯加、夏威夷分区布局
|
||||||
|
- 俄罗斯国土跨度适配投影
|
||||||
|
- 河北省下钻至 11 个地级市
|
||||||
|
- 省市级代表产物、产季和产区说明
|
||||||
|
|
||||||
打开 `/approval`,或点击后台首页右下角“审批流程设计”入口。设计器包含:
|
## 技术栈
|
||||||
|
|
||||||
- 左侧审批、条件分支和抄送节点库
|
- React 19
|
||||||
- 节点点击添加及拖入画布
|
- Next.js 16 页面结构
|
||||||
- 审批条件双分支与合流展示
|
- vinext / Vite 8 构建与运行
|
||||||
- 单人审批、或签、会签规则
|
- TypeScript 5
|
||||||
- 审批人、表单权限与高级规则配置
|
- CSS Modules
|
||||||
- 流程校验、模拟预览、JSON 查看与复制
|
- D3 Geo 地理数据投影
|
||||||
- 自动保存、恢复示例及本地发布状态
|
- Cloudflare Workers 兼容构建
|
||||||
|
- JSON 和 `localStorage` 本地数据模拟
|
||||||
|
|
||||||
示例流程数据位于 `app/data/approval-flow.json`。
|
## 快速启动
|
||||||
|
|
||||||
## 本地运行
|
运行环境需要 Node.js `>= 22.13.0`。
|
||||||
|
|
||||||
需要 Node.js `>=22.13.0`。
|
### Windows 一键启动
|
||||||
|
|
||||||
|
直接双击仓库根目录中的:
|
||||||
|
|
||||||
|
```text
|
||||||
|
run.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以在 PowerShell 中运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location "D:\RECS-WEB-MODLE-REACT"
|
||||||
|
npm run start:local
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令会自动处理 Windows 静态资源路径兼容问题、执行生产构建,并在以下地址启动完整项目:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000/
|
||||||
|
```
|
||||||
|
|
||||||
|
按 `Ctrl + C` 可以停止服务。
|
||||||
|
|
||||||
|
### 常规开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev -- --port 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
生产构建与测试:
|
### 构建与检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run lint
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
## 数据位置
|
## 项目结构
|
||||||
|
|
||||||
- `app/data/mock-data.json`:概览、项目、泳道和定时任务
|
```text
|
||||||
- `app/data/approval-flow.json`:审批流程、条件分支和节点配置
|
WBBR/
|
||||||
- `app/data/work-list.json`:树形工作项及层级关系
|
├─ app/
|
||||||
|
│ ├─ TemplateHub.tsx # 模板主导航页
|
||||||
|
│ ├─ admin/ # 后台组件模板
|
||||||
|
│ ├─ approval/ # 审批流程设计器
|
||||||
|
│ ├─ worklist/ # 多级工作列表
|
||||||
|
│ ├─ produce-map/ # 全球产物地图及河北市级下钻
|
||||||
|
│ ├─ media-player/ # 视频播放器
|
||||||
|
│ ├─ music-player/ # 音乐播放器
|
||||||
|
│ ├─ data/ # 页面演示 JSON 数据
|
||||||
|
│ └─ ... # 其他独立模板页面
|
||||||
|
├─ public/
|
||||||
|
│ ├─ maps/ # 国家、省市行政区 GeoJSON
|
||||||
|
│ ├─ anime/ # 作品与内容示例图片
|
||||||
|
│ └─ seasonal/ # 日历及应季素材
|
||||||
|
├─ scripts/
|
||||||
|
│ └─ start-local.mjs # Windows 一键构建启动脚本
|
||||||
|
├─ tests/ # 页面构建结果检查
|
||||||
|
├─ run.cmd # Windows 本地一键启动入口
|
||||||
|
├─ 启动项目.cmd # 中文名称启动入口
|
||||||
|
├─ package.json
|
||||||
|
└─ README.md
|
||||||
|
```
|
||||||
|
|
||||||
后续接入真实接口时,可以保留页面组件,只替换数据读取方式。当前 `.openai/hosting.json` 中的 D1 和 R2 均为 `null`,项目不依赖数据库或对象存储。
|
## 数据文件
|
||||||
|
|
||||||
## 主要文件
|
| 文件 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `app/data/mock-data.json` | 后台概览、组件及定时任务 |
|
||||||
|
| `app/data/approval-flow.json` | 审批节点、条件和流程配置 |
|
||||||
|
| `app/data/work-list.json` | 多级工作项及层级关系 |
|
||||||
|
| `app/data/kanban-board.json` | 泳道和任务卡片 |
|
||||||
|
| `app/data/repeatable-forms.json` | 可重复表单内容 |
|
||||||
|
| `app/data/media-player.json` | 视频、音乐和播放列表 |
|
||||||
|
| `app/data/seasonal-calendar.json` | 日历事件和应季信息 |
|
||||||
|
| `app/data/produce-map.json` | 国家、行政区和代表产物 |
|
||||||
|
| `app/data/star-graph.json` | 星空关系节点和连线 |
|
||||||
|
| `app/data/web-icons.json` | 网页图标分类与代码 |
|
||||||
|
|
||||||
- `app/AdminApp.tsx`:后台主界面和交互逻辑
|
地图边界文件位于 `public/maps`,其中包括国家一级行政区数据和河北省市级行政区示例数据。
|
||||||
- `app/worklist/page.tsx`:多级工作列表逻辑
|
|
||||||
- `app/worklist/WorkList.module.css`:树形表格视觉样式
|
|
||||||
- `app/approval/page.tsx`:审批流程设计器逻辑
|
|
||||||
- `app/approval/ApprovalDesigner.module.css`:设计器视觉与布局
|
|
||||||
- `app/globals.css`:后台视觉系统与响应式样式
|
|
||||||
|
|
||||||
## 注意
|
## 数据保存方式
|
||||||
|
|
||||||
所有新增和状态修改只写入浏览器本地存储。清除浏览器站点数据后会恢复为 JSON 中的初始内容。
|
本项目暂时不依赖后端:
|
||||||
|
|
||||||
|
- 初始演示内容来自本地 JSON
|
||||||
|
- 页面编辑结果按功能保存在浏览器 `localStorage`
|
||||||
|
- 清除浏览器站点数据后,页面会恢复到 JSON 初始状态
|
||||||
|
- `.openai/hosting.json` 中的 D1 和 R2 均为 `null`
|
||||||
|
|
||||||
|
## 接入真实接口
|
||||||
|
|
||||||
|
接入后端时建议保留页面组件和类型定义,只替换数据层:
|
||||||
|
|
||||||
|
1. 将 JSON 导入替换为 `fetch` 或项目统一请求客户端。
|
||||||
|
2. 将 `localStorage` 保存逻辑替换为新增、修改和删除接口。
|
||||||
|
3. 为加载、失败和空数据状态接入真实返回结果。
|
||||||
|
4. 保持现有数据字段,或在接口层增加字段转换函数。
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 后台管理系统原型
|
||||||
|
- 产品需求与交互演示
|
||||||
|
- 前端页面模板选型
|
||||||
|
- 无后端条件下的业务验证
|
||||||
|
- React / TypeScript 页面开发参考
|
||||||
|
- 新项目的管理端或内容端基础工程
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function Home() {
|
|||||||
<aside className={`sidebar ${sidebarOpen ? "is-open" : ""}`}>
|
<aside className={`sidebar ${sidebarOpen ? "is-open" : ""}`}>
|
||||||
<button className="brand" onClick={() => navigate("overview")} aria-label="返回概览">
|
<button className="brand" onClick={() => navigate("overview")} aria-label="返回概览">
|
||||||
<span className="brand-mark"><i /><i /><i /></span>
|
<span className="brand-mark"><i /><i /><i /></span>
|
||||||
<span>Orbit<span>Flow</span></span>
|
<span>WBBR<span>UI</span></span>
|
||||||
</button>
|
</button>
|
||||||
<nav className="main-nav" aria-label="主导航">
|
<nav className="main-nav" aria-label="主导航">
|
||||||
{navGroups.map((group) => (
|
{navGroups.map((group) => (
|
||||||
@@ -155,7 +155,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<section className="page-content">
|
<section className="page-content">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div><p className="eyebrow">{activeView === "overview" ? "2026 年 8 月 7 日 · 星期五" : "ORBITFLOW TEMPLATE"}</p><h1>{pageMeta[activeView].title}</h1><p>{pageMeta[activeView].subtitle}</p></div>
|
<div><p className="eyebrow">{activeView === "overview" ? "2026 年 8 月 7 日 · 星期五" : "WBBR STYLE TEMPLATE"}</p><h1>{pageMeta[activeView].title}</h1><p>{pageMeta[activeView].subtitle}</p></div>
|
||||||
<div className="heading-actions"><button className="button secondary" onClick={() => notify("示例数据已刷新")}>↻ 刷新数据</button><button className="button primary" onClick={() => activeView === "schedule" ? setScheduleModal(true) : notify("已创建一条示例记录")}>+ {activeView === "schedule" ? "新建任务" : "快速创建"}</button></div>
|
<div className="heading-actions"><button className="button secondary" onClick={() => notify("示例数据已刷新")}>↻ 刷新数据</button><button className="button primary" onClick={() => activeView === "schedule" ? setScheduleModal(true) : notify("已创建一条示例记录")}>+ {activeView === "schedule" ? "新建任务" : "快速创建"}</button></div>
|
||||||
</div>
|
</div>
|
||||||
{activeView === "overview" && <Overview onNavigate={navigate} notify={notify} />}
|
{activeView === "overview" && <Overview onNavigate={navigate} notify={notify} />}
|
||||||
|
|||||||
1
app/TemplateHub.module.css
Normal file
1
app/TemplateHub.module.css
Normal file
File diff suppressed because one or more lines are too long
123
app/TemplateHub.tsx
Normal file
123
app/TemplateHub.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CSSProperties, useMemo, useState } from "react";
|
||||||
|
import styles from "./TemplateHub.module.css";
|
||||||
|
|
||||||
|
type Category = "全部模板" | "后台业务" | "内容资料" | "可视化工具";
|
||||||
|
|
||||||
|
type TemplateItem = {
|
||||||
|
title: string;
|
||||||
|
english: string;
|
||||||
|
href: string;
|
||||||
|
category: Exclude<Category, "全部模板">;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
color: string;
|
||||||
|
soft: string;
|
||||||
|
cover?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categories: Category[] = ["全部模板", "后台业务", "内容资料", "可视化工具"];
|
||||||
|
|
||||||
|
const templates: TemplateItem[] = [
|
||||||
|
{ title: "后台组件总览", english: "ADMIN FOUNDATION", href: "/admin", category: "后台业务", icon: "▦", description: "标签、菜单、按钮、图文列表、定时任务等后台基础组件。", tags: ["组件库", "定时任务", "菜单"], color: "#356df3", soft: "#eaf1ff", featured: true },
|
||||||
|
{ title: "审批流程设计", english: "APPROVAL FLOW", href: "/approval", category: "后台业务", icon: "⌘", description: "类似飞书的前端审批流程图编辑器,支持节点配置。", tags: ["流程图", "节点编辑", "JSON"], color: "#7b61e8", soft: "#f0edff", featured: true },
|
||||||
|
{ title: "多级工作列表", english: "WORK LIST", href: "/worklist", category: "后台业务", icon: "☷", description: "树形多级表格、展开收起、状态筛选和批量操作。", tags: ["树形表格", "筛选", "批量操作"], color: "#315779", soft: "#eaf1f6" },
|
||||||
|
{ title: "泳道任务看板", english: "SWIMLANE KANBAN", href: "/kanban", category: "后台业务", icon: "▥", description: "卡片可在不同泳道之间拖动并即时更新状态。", tags: ["拖拽", "泳道", "任务卡片"], color: "#2d8a65", soft: "#e6f6ef" },
|
||||||
|
{ title: "响应式图床", english: "IMAGE HOST", href: "/image-host", category: "内容资料", icon: "▣", description: "瀑布流图片、三秒大图预览、单图详情与图集浏览。", tags: ["瀑布流", "图集", "悬停预览"], color: "#ef7955", soft: "#fff0e9", cover: "/anime/star-echoes-hero.png", featured: true },
|
||||||
|
{ title: "视频播放器", english: "VIDEO PLAYER", href: "/media-player", category: "内容资料", icon: "▶", description: "自定义视频控制器,支持进度、倍速、画中画与全屏。", tags: ["视频", "画中画", "本地文件"], color: "#5e63ef", soft: "#eeefff" },
|
||||||
|
{ title: "音乐播放器", english: "MUSIC PLAYER", href: "/music-player", category: "内容资料", icon: "♫", description: "独立音乐播放页,包含播放列表、波形进度和同步歌词。", tags: ["音乐", "歌词", "播放列表"], color: "#9a59df", soft: "#f5ecff" },
|
||||||
|
{ title: "时令事件日历", english: "SEASONAL CALENDAR", href: "/calendar", category: "内容资料", icon: "历", description: "按日期查看水果、海鲜应季期与阶段性事项,点击日期同步显示当天详情。", tags: ["日历", "应季时间", "事件详情"], color: "#d96246", soft: "#fff0e9" },
|
||||||
|
{ title: "全球产物地图", english: "GLOBAL PRODUCE MAP", href: "/produce-map", category: "可视化工具", icon: "地", description: "按中国、日本、中东、欧美等地区切换地图,高亮一级行政区并查看主要作物。", tags: ["地图", "行政区域", "主要产物"], color: "#2f8468", soft: "#e8f5ef" },
|
||||||
|
{ title: "动漫作品详情", english: "ANIME DETAIL", href: "/anime", category: "内容资料", icon: "星", description: "作品信息、商品展示、剧集列表和评论展示模板。", tags: ["作品", "商品", "评论"], color: "#6f57c9", soft: "#f0edfa", cover: "/anime/star-echoes-hero.png" },
|
||||||
|
{ title: "角色详情页", english: "CHARACTER PROFILE", href: "/character", category: "内容资料", icon: "人", description: "角色标签、关系、基本档案与剧情轨迹集中展示。", tags: ["角色", "标签", "关系"], color: "#9a5cc5", soft: "#f6edfb" },
|
||||||
|
{ title: "可重复表单", english: "REPEATABLE FORM", href: "/repeatable-form", category: "内容资料", icon: "F+", description: "可重复添加表单明细,并为每项配置图片和文件列表。", tags: ["动态表单", "图片", "文件"], color: "#3c72d9", soft: "#ebf2ff" },
|
||||||
|
{ title: "Markdown 编辑器", english: "MARKDOWN EDITOR", href: "/markdown", category: "内容资料", icon: "M↓", description: "Markdown 文档编辑、实时预览和浏览器本地保存。", tags: ["文档", "实时预览", "本地保存"], color: "#51586b", soft: "#eff1f5" },
|
||||||
|
{ title: "简单列表", english: "SIMPLE LIST", href: "/simple-list", category: "内容资料", icon: "≡", description: "轻量搜索、标签筛选、分页与批量操作列表。", tags: ["列表", "搜索", "标签"], color: "#625cb5", soft: "#eeedfa" },
|
||||||
|
{ title: "数据大屏", english: "DATA WALL", href: "/bigscreen", category: "可视化工具", icon: "◫", description: "适用于经营数据、实时指标和趋势图表的大屏模板。", tags: ["大屏", "指标", "图表"], color: "#16869a", soft: "#e5f6f8" },
|
||||||
|
{ title: "星空关系网络", english: "STAR GRAPH", href: "/stargraph", category: "可视化工具", icon: "✦", description: "星空超图节点、直线关联、悬停详情和 JSON 导入导出。", tags: ["关系图", "节点", "Canvas"], color: "#7063db", soft: "#efedff" },
|
||||||
|
{ title: "网页图标库", english: "WEB ICON LIBRARY", href: "/icons", category: "可视化工具", icon: "◇", description: "分类搜索、预览和复制代码的网页原生图标选择库。", tags: ["图标", "搜索", "代码复制"], color: "#38445b", soft: "#edf0f5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TemplateHub() {
|
||||||
|
const [activeCategory, setActiveCategory] = useState<Category>("全部模板");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => templates.filter((item) => {
|
||||||
|
const matchesCategory = activeCategory === "全部模板" || item.category === activeCategory;
|
||||||
|
const searchable = `${item.title} ${item.english} ${item.description} ${item.tags.join(" ")}`.toLowerCase();
|
||||||
|
return matchesCategory && searchable.includes(query.trim().toLowerCase());
|
||||||
|
}), [activeCategory, query]);
|
||||||
|
|
||||||
|
const visibleGroups = categories.slice(1).map((category) => ({
|
||||||
|
category: category as Exclude<Category, "全部模板">,
|
||||||
|
items: filtered.filter((item) => item.category === category),
|
||||||
|
})).filter((group) => group.items.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.brand}><span>W</span><div><strong>WBBR</strong><small>AI FRONTEND STYLE SYSTEM</small></div></div>
|
||||||
|
<nav><a href="#templates">全部模板</a><a href="#about">使用说明</a></nav>
|
||||||
|
<div className={styles.version}><i />纯前端演示 <b>v1.0</b></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<div className={styles.heroCopy}>
|
||||||
|
<p>AI FRONTEND STYLE REFERENCE</p>
|
||||||
|
<h1>为项目内的 AI 提供明确的<br /><em>前端样式基准</em></h1>
|
||||||
|
<span>用可运行的真实页面定义布局、组件与交互风格。覆盖后台管理、内容资料和数据可视化,全部使用本地 JSON 演示。</span>
|
||||||
|
<div className={styles.heroActions}><a href="#templates">浏览全部模板 ↓</a><Link href="/admin">打开后台总览 →</Link></div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroVisual} aria-hidden="true">
|
||||||
|
<div className={styles.visualWindow}><span><i /><i /><i /><b>wbbr.local/templates</b></span><div className={styles.visualBody}><aside><i /><i /><i /><i /></aside><main><p /><section><b /><b /><b /></section><div><i /><i /><i /><i /><i /><i /></div></main></div></div>
|
||||||
|
<div className={styles.floatingCard}><span>✓</span><p><strong>{templates.length} 个模板</strong><small>全部可以本地运行</small></p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.featured} aria-label="精选模板">
|
||||||
|
{templates.filter((item) => item.featured).map((item, index) => <Link href={item.href} key={item.href} className={styles.featuredCard} style={{ "--accent": item.color, "--soft": item.soft } as CSSProperties}><span>0{index + 1}</span><div><small>{item.english}</small><strong>{item.title}</strong><p>{item.description}</p></div><b>↗</b></Link>)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.templates} id="templates">
|
||||||
|
<div className={styles.sectionHeading}><div><p>ALL TEMPLATES</p><h2>全部模板导航</h2><span>选择分类,快速进入需要的前端模板。</span></div><label><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索模板或功能" />{query && <button onClick={() => setQuery("")}>×</button>}</label></div>
|
||||||
|
<div className={styles.filters}>{categories.map((category) => <button key={category} className={activeCategory === category ? styles.filterActive : ""} onClick={() => setActiveCategory(category)}>{category}<b>{category === "全部模板" ? templates.length : templates.filter((item) => item.category === category).length}</b></button>)}</div>
|
||||||
|
|
||||||
|
{visibleGroups.map((group) => <section className={styles.group} key={group.category}>
|
||||||
|
<div className={styles.groupTitle}><span>{categoryIcon(group.category)}</span><div><h3>{group.category}</h3><p>{categoryDescription(group.category)}</p></div><b>{group.items.length}</b></div>
|
||||||
|
<div className={styles.grid}>{group.items.map((item) => <TemplateCard key={item.href} item={item} />)}</div>
|
||||||
|
</section>)}
|
||||||
|
{filtered.length === 0 && <div className={styles.empty}><span>⌕</span><h3>没有找到匹配模板</h3><p>换一个关键词,或者清除当前筛选。</p><button onClick={() => { setQuery(""); setActiveCategory("全部模板"); }}>显示全部模板</button></div>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.about} id="about"><div><p>LOCAL JSON · NO BACKEND</p><h2>模板暂时可用,也方便继续扩展</h2></div><span>每个页面都是独立路由,演示数据保存在 JSON 或浏览器本地存储中。后续可以直接替换为真实接口数据。</span><Link href="/admin">从后台基础模板开始 →</Link></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer><div><span>W</span><strong>WBBR</strong></div><p>AI 前端样式基准 · React 与本地 JSON 模板</p><a href="#templates">返回模板列表 ↑</a></footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateCard({ item }: { item: TemplateItem }) {
|
||||||
|
const style = {
|
||||||
|
"--accent": item.color,
|
||||||
|
"--soft": item.soft,
|
||||||
|
...(item.cover ? { backgroundImage: `linear-gradient(135deg, rgba(12,18,32,.16), rgba(12,18,32,.62)), url(${item.cover})` } : {}),
|
||||||
|
} as CSSProperties;
|
||||||
|
return <Link href={item.href} className={`${styles.card} ${item.cover ? styles.cardWithCover : ""}`} style={style}>
|
||||||
|
<div className={styles.cardPreview}><span>{item.icon}</span><small>{item.english}</small><div><i /><i /><i /></div></div>
|
||||||
|
<div className={styles.cardBody}><div><strong>{item.title}</strong><b>打开模板 ↗</b></div><p>{item.description}</p><span>{item.tags.map((tag) => <i key={tag}>{tag}</i>)}</span></div>
|
||||||
|
</Link>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryIcon(category: Exclude<Category, "全部模板">) {
|
||||||
|
return category === "后台业务" ? "▦" : category === "内容资料" ? "◫" : "✦";
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryDescription(category: Exclude<Category, "全部模板">) {
|
||||||
|
return category === "后台业务" ? "流程、任务与管理工作台" : category === "内容资料" ? "列表、文档与内容展示" : "数据、关系与辅助工具";
|
||||||
|
}
|
||||||
3
app/admin/AdminPage.module.css
Normal file
3
app/admin/AdminPage.module.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.backToHub{position:fixed;right:24px;top:18px;z-index:55;padding:9px 13px;border:1px solid #dfe4ed;border-radius:8px;background:rgba(255,255,255,.94);box-shadow:0 8px 22px rgba(24,39,67,.1);color:#536077;text-decoration:none;font-size:11px;backdrop-filter:blur(8px)}
|
||||||
|
.backToHub:hover{border-color:#9db7ed;color:#3276f6}
|
||||||
|
@media(max-width:620px){.backToHub{right:12px;top:13px;padding:8px;font-size:9px}}
|
||||||
7
app/admin/page.tsx
Normal file
7
app/admin/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import AdminApp from "../AdminApp";
|
||||||
|
import styles from "./AdminPage.module.css";
|
||||||
|
|
||||||
|
export default function AdminTemplatePage() {
|
||||||
|
return <div><AdminApp /><Link className={styles.backToHub} href="/">← 模板中心</Link></div>;
|
||||||
|
}
|
||||||
302
app/anime/AnimeDetail.module.css
Normal file
302
app/anime/AnimeDetail.module.css
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 12px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||||
|
|
||||||
|
.page {
|
||||||
|
--night: #080b19;
|
||||||
|
--ink: #17182b;
|
||||||
|
--purple: #7c5cff;
|
||||||
|
--purple-dark: #5e42d9;
|
||||||
|
--purple-soft: #f0edff;
|
||||||
|
--line: #e9e7f0;
|
||||||
|
--muted: #7c7d8e;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: clip;
|
||||||
|
background: #fbfafc;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page button, .page textarea { font: inherit; }
|
||||||
|
.header { position: absolute; inset: 0 0 auto; z-index: 20; height: 76px; display: flex; align-items: center; gap: 48px; padding: 0 max(28px, calc((100vw - 1240px) / 2)); border-bottom: 1px solid rgba(255,255,255,.12); color: white; }
|
||||||
|
.logo { display: flex; align-items: center; gap: 11px; color: white; text-decoration: none; }
|
||||||
|
.logoMark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid rgba(255,255,255,.65); border-radius: 50%; background: linear-gradient(145deg,#8c6bff,#5e3fd1); color: #fff; font-family: Georgia,serif; font-size: 18px; font-style: italic; box-shadow: 0 5px 16px rgba(67,35,169,.3); }
|
||||||
|
.logo > span:last-child { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.logo strong { font-size: 13px; letter-spacing: 3px; }
|
||||||
|
.logo small { color: rgba(255,255,255,.55); font-size: 7px; letter-spacing: 2px; }
|
||||||
|
.primaryNav { display: flex; align-self: stretch; }
|
||||||
|
.primaryNav button { position: relative; min-width: 68px; padding: 0 14px; border: 0; background: none; color: rgba(255,255,255,.62); cursor: pointer; font-size: 13px; }
|
||||||
|
.primaryNav button:hover, .primaryNav .currentNav { color: #fff; }
|
||||||
|
.primaryNav .currentNav::after { content: ""; position: absolute; inset: auto 14px -1px; height: 2px; background: #a68cff; }
|
||||||
|
.headerActions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.headerActions > button { width: 38px; height: 38px; border: 0; border-radius: 50%; background: rgba(255,255,255,.09); color: white; cursor: pointer; font-size: 20px; }
|
||||||
|
.headerActions > button:hover { background: rgba(255,255,255,.18); }
|
||||||
|
.headerActions .cartButton { position: relative; font-size: 16px; }
|
||||||
|
.cartButton span { position: absolute; right: -2px; top: -2px; min-width: 17px; height: 17px; display: grid; place-items: center; padding: 0 4px; border-radius: 9px; background: #ff6d8a; font-size: 9px; }
|
||||||
|
.headerAvatar { width: 36px; height: 36px; display: grid; place-items: center; margin-left: 5px; border: 1px solid rgba(255,255,255,.5); border-radius: 50%; background: linear-gradient(135deg,#d7d0ff,#8a73ea); color: #2c2255; font-size: 12px; font-weight: 800; }
|
||||||
|
|
||||||
|
.hero { position: relative; height: 720px; overflow: hidden; background: #080b19; color: #fff; }
|
||||||
|
.heroImage { position: absolute; inset: 0; background-image: url('/anime/star-echoes-hero.png'); background-position: center 42%; background-size: cover; transform: scale(1.002); }
|
||||||
|
.heroShade { position: absolute; inset: 0; background: linear-gradient(90deg,rgba(5,7,20,.98) 0%,rgba(6,8,24,.89) 25%,rgba(8,10,26,.41) 54%,rgba(8,10,25,.11) 80%),linear-gradient(0deg,rgba(8,11,25,1) 0%,transparent 35%,rgba(4,7,20,.35) 100%); }
|
||||||
|
.heroContent { position: relative; width: min(1240px,calc(100% - 56px)); height: 100%; margin: auto; padding-top: 108px; }
|
||||||
|
.breadcrumb { display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.42); font-size: 10px; }
|
||||||
|
.breadcrumb a { color: rgba(255,255,255,.5); text-decoration: none; }
|
||||||
|
.breadcrumb strong { color: rgba(255,255,255,.76); font-weight: 500; }
|
||||||
|
.heroCopy { width: min(590px,50vw); margin-top: 80px; }
|
||||||
|
.statusLine { display: flex; align-items: center; gap: 8px; margin-bottom: 21px; }
|
||||||
|
.statusLine > span { padding: 4px 8px; border: 1px solid rgba(255,255,255,.18); border-radius: 4px; color: rgba(255,255,255,.67); font-size: 10px; }
|
||||||
|
.statusLine .onAir { display: flex; align-items: center; gap: 6px; border-color: rgba(125,255,207,.28); background: rgba(48,166,125,.13); color: #8ef0cb; }
|
||||||
|
.onAir i { width: 5px; height: 5px; border-radius: 50%; background: #74e8bd; box-shadow: 0 0 8px #74e8bd; }
|
||||||
|
.englishTitle { margin: 0 0 6px; color: #ad9bff; font-size: 10px; font-weight: 700; letter-spacing: 3.6px; }
|
||||||
|
.hero h1 { margin: 0; font-family: "STSong","SimSun",serif; font-size: clamp(60px,6vw,92px); font-weight: 500; letter-spacing: 8px; line-height: 1.08; text-shadow: 0 4px 22px rgba(0,0,0,.25); }
|
||||||
|
.heroSubtitle { max-width: 520px; margin: 20px 0 17px; color: rgba(255,255,255,.76); font-family: "STSong","SimSun",serif; font-size: 16px; line-height: 1.8; letter-spacing: .5px; }
|
||||||
|
.tags { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||||
|
.tags span { padding: 5px 11px; border-radius: 16px; background: rgba(255,255,255,.1); color: rgba(255,255,255,.72); font-size: 10px; backdrop-filter: blur(8px); }
|
||||||
|
.heroButtons { display: flex; align-items: center; gap: 10px; margin-top: 28px; }
|
||||||
|
.heroButtons button { height: 44px; border-radius: 24px; cursor: pointer; font-size: 12px; font-weight: 700; transition: transform .18s,background .18s; }
|
||||||
|
.heroButtons button:hover { transform: translateY(-2px); }
|
||||||
|
.playButton { min-width: 152px; padding: 0 22px; border: 0; background: #fff; color: #19142c; box-shadow: 0 9px 28px rgba(0,0,0,.2); }
|
||||||
|
.playButton span { margin-right: 8px; color: var(--purple); }
|
||||||
|
.favoriteButton, .favoritedButton { min-width: 96px; padding: 0 18px; border: 1px solid rgba(255,255,255,.36); background: rgba(14,15,35,.35); color: #fff; backdrop-filter: blur(8px); }
|
||||||
|
.favoritedButton { border-color: #9e89ff; background: rgba(124,92,255,.32); }
|
||||||
|
.roundButton { width: 44px; border: 1px solid rgba(255,255,255,.3); background: rgba(14,15,35,.35); color: #fff; }
|
||||||
|
.heroRating { position: absolute; right: 0; bottom: 112px; width: 236px; padding: 19px 21px; border: 1px solid rgba(255,255,255,.16); border-radius: 13px; background: rgba(13,14,33,.44); backdrop-filter: blur(16px); }
|
||||||
|
.heroRating > small { color: rgba(255,255,255,.53); font-size: 9px; letter-spacing: 1px; }
|
||||||
|
.heroRating > div { display: flex; align-items: center; gap: 12px; margin-top: 6px; }
|
||||||
|
.heroRating > div > strong { font-family: Georgia,serif; font-size: 40px; font-weight: 500; }
|
||||||
|
.heroRating > div > span { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.heroRating b { color: #ffd36d; font-size: 10px; letter-spacing: 1px; }
|
||||||
|
.heroRating span small { color: rgba(255,255,255,.48); font-size: 8px; }
|
||||||
|
.heroRating > i { display: block; height: 1px; margin: 13px 0 10px; background: rgba(255,255,255,.13); }
|
||||||
|
.heroRating p { display: flex; justify-content: space-between; margin: 0; font-size: 9px; }
|
||||||
|
.heroRating p span { color: rgba(255,255,255,.48); }
|
||||||
|
.heroRating p b { color: #bcaeff; font-size: 9px; }
|
||||||
|
.heroMetrics { position: absolute; left: 0; bottom: 45px; display: flex; }
|
||||||
|
.heroMetrics > div { min-width: 110px; padding-right: 34px; margin-right: 34px; border-right: 1px solid rgba(255,255,255,.14); }
|
||||||
|
.heroMetrics > div:last-child { border-right: 0; }
|
||||||
|
.heroMetrics small { display: block; margin-bottom: 4px; color: rgba(255,255,255,.42); font-size: 9px; }
|
||||||
|
.heroMetrics strong { font-family: Georgia,serif; font-size: 20px; font-weight: 500; }
|
||||||
|
.heroMetrics em { color: rgba(255,255,255,.38); font-size: 12px; font-style: normal; }
|
||||||
|
|
||||||
|
.sectionNav { position: sticky; top: 0; z-index: 15; height: 64px; border-bottom: 1px solid var(--line); background: rgba(251,250,252,.94); backdrop-filter: blur(14px); }
|
||||||
|
.sectionNav > div { width: min(1240px,calc(100% - 56px)); height: 100%; display: flex; align-items: stretch; gap: 36px; margin: auto; }
|
||||||
|
.sectionNav button { position: relative; padding: 0 4px; border: 0; background: transparent; color: #8b8995; cursor: pointer; font-size: 12px; }
|
||||||
|
.sectionNav .activeTab { color: var(--ink); font-weight: 700; }
|
||||||
|
.sectionNav .activeTab::after { content: ""; position: absolute; inset: auto 0 -1px; height: 2px; background: var(--purple); }
|
||||||
|
.content { width: min(1240px,calc(100% - 56px)); margin: auto; padding: 4px 0 80px; }
|
||||||
|
.anchorSection { scroll-margin-top: 84px; padding-top: 74px; }
|
||||||
|
.sectionHeading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 27px; }
|
||||||
|
.sectionHeading > div > span, .kicker { display: block; margin-bottom: 7px; color: var(--purple); font-size: 8px; font-weight: 800; letter-spacing: 2.5px; }
|
||||||
|
.sectionHeading h2 { margin: 0; font-family: "STSong","SimSun",serif; font-size: 28px; font-weight: 600; letter-spacing: 1px; }
|
||||||
|
.sectionHeading > p { margin: 0; color: #9a98a4; font-size: 9px; }
|
||||||
|
.textButton { border: 0; background: none; color: #7163ad; cursor: pointer; font-size: 10px; }
|
||||||
|
|
||||||
|
.overviewGrid { display: grid; grid-template-columns: minmax(0,2.3fr) minmax(280px,.8fr); gap: 20px; }
|
||||||
|
.synopsisCard { min-height: 444px; display: grid; grid-template-columns: 39% 1fr; overflow: hidden; border: 1px solid var(--line); border-radius: 15px; background: #fff; box-shadow: 0 15px 50px rgba(29,20,58,.06); }
|
||||||
|
.posterCrop { min-height: 444px; background-image: linear-gradient(0deg,rgba(8,9,22,.35),transparent 40%),url('/anime/star-echoes-hero.png'); background-position: 65% center; background-size: auto 100%; }
|
||||||
|
.synopsisBody { padding: 50px 46px 40px; }
|
||||||
|
.synopsisBody h3 { margin: 0 0 23px; font-family: "STSong","SimSun",serif; font-size: 26px; font-weight: 600; line-height: 1.62; letter-spacing: 1px; }
|
||||||
|
.synopsisBody > p { margin: 0; color: #6f6e7b; font-size: 12px; line-height: 2.05; }
|
||||||
|
.quote { margin-top: 25px; padding: 15px 17px; border-left: 2px solid #9c88fa; background: #f7f5ff; color: #6b608e; font-family: "STSong","SimSun",serif; font-size: 11px; line-height: 1.8; }
|
||||||
|
.infoCard { padding: 28px; border: 1px solid var(--line); border-radius: 15px; background: #fff; }
|
||||||
|
.infoCard h3 { margin: 0 0 23px; font-size: 13px; }
|
||||||
|
.infoCard dl { margin: 0; }
|
||||||
|
.infoCard dl > div { display: flex; justify-content: space-between; gap: 18px; padding: 11px 0; border-bottom: 1px solid #f0eef3; font-size: 10px; }
|
||||||
|
.infoCard dt { color: #9a98a3; }
|
||||||
|
.infoCard dd { margin: 0; color: #444250; font-weight: 600; text-align: right; }
|
||||||
|
.notice { display: flex; gap: 10px; margin-top: 22px; padding: 13px; border-radius: 9px; background: var(--purple-soft); }
|
||||||
|
.notice > span { width: 20px; height: 20px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #8169e4; color: #fff; font-family: Georgia,serif; font-size: 11px; font-style: italic; }
|
||||||
|
.notice p { margin: 0; color: #6a6285; font-size: 9px; line-height: 1.6; }
|
||||||
|
.notice p strong { display: block; color: #473a77; font-size: 10px; }
|
||||||
|
|
||||||
|
.episodeFeature { display: grid; grid-template-columns: 42% 1fr; min-height: 220px; overflow: hidden; margin-bottom: 16px; border: 1px solid var(--line); border-radius: 15px; background: #fff; }
|
||||||
|
.episodeVisual { position: relative; display: grid; place-items: center; overflow: hidden; background-image: linear-gradient(90deg,rgba(6,8,23,.15),rgba(6,8,23,.35)),url('/anime/star-echoes-hero.png'); background-position: 58% center; background-size: cover; color: #fff; }
|
||||||
|
.episodeVisual::after { content: ""; position: absolute; inset: 0; box-shadow: inset -35px 0 35px rgba(8,10,28,.08); }
|
||||||
|
.episodeVisual > span { position: absolute; left: 18px; top: 17px; z-index: 1; padding: 4px 8px; border-radius: 4px; background: rgba(8,10,28,.55); font-size: 8px; backdrop-filter: blur(6px); }
|
||||||
|
.episodeVisual button { position: relative; z-index: 2; width: 54px; height: 54px; border: 1px solid rgba(255,255,255,.8); border-radius: 50%; background: rgba(255,255,255,.18); color: #fff; cursor: pointer; font-size: 17px; backdrop-filter: blur(6px); }
|
||||||
|
.episodeVisual small { position: absolute; right: 18px; bottom: 14px; z-index: 1; font-size: 9px; }
|
||||||
|
.episodeFeature > div:last-child { align-self: center; padding: 34px 42px; }
|
||||||
|
.episodeFeature h3 { margin: 0 0 10px; font-size: 18px; }
|
||||||
|
.episodeFeature p { margin: 0 0 25px; color: #85838f; font-size: 11px; }
|
||||||
|
.watchProgress { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.watchProgress > span { height: 4px; flex: 1; overflow: hidden; border-radius: 2px; background: #eeeaf4; }
|
||||||
|
.watchProgress i { display: block; height: 100%; border-radius: inherit; background: var(--purple); }
|
||||||
|
.watchProgress small { color: #9b98a5; font-size: 8px; }
|
||||||
|
.episodeGrid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 9px; }
|
||||||
|
.episodeCard { min-height: 76px; display: flex; align-items: center; gap: 14px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 10px; background: #fff; color: var(--ink); cursor: pointer; text-align: left; transition: border .18s,transform .18s,box-shadow .18s; }
|
||||||
|
.episodeCard:hover { transform: translateY(-2px); border-color: #cfc5fa; box-shadow: 0 8px 22px rgba(49,31,101,.07); }
|
||||||
|
.episodeNumber { width: 31px; color: #aaa5b6; font-family: Georgia,serif; font-size: 18px; }
|
||||||
|
.episodeInfo { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 5px; }
|
||||||
|
.episodeInfo strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.episodeInfo small { color: #aaa8b2; font-size: 8px; }
|
||||||
|
.episodeCard > i { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 50%; background: #f2effb; color: #8370d6; font-size: 8px; font-style: normal; }
|
||||||
|
.selectedEpisode { border-color: #9c88f1; background: #faf9ff; box-shadow: inset 3px 0 #7c5cff; }
|
||||||
|
.selectedEpisode .episodeNumber { color: var(--purple); }
|
||||||
|
.lockedEpisode { opacity: .52; background: #f5f4f6; }
|
||||||
|
|
||||||
|
.productBanner { min-height: 148px; display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; padding: 30px 42px; border-radius: 15px; background: radial-gradient(circle at 80% 20%,rgba(168,140,255,.22),transparent 32%),linear-gradient(110deg,#15132a,#282043 58%,#111326); color: #fff; }
|
||||||
|
.productBanner span { color: #b6a4ff; font-size: 9px; letter-spacing: 2px; }
|
||||||
|
.productBanner h3 { margin: 8px 0 5px; font-family: "STSong","SimSun",serif; font-size: 25px; font-weight: 500; letter-spacing: 2px; }
|
||||||
|
.productBanner p { margin: 0; color: rgba(255,255,255,.48); font-size: 9px; }
|
||||||
|
.productBanner button { height: 38px; padding: 0 18px; border: 1px solid rgba(255,255,255,.46); border-radius: 20px; background: transparent; color: #fff; cursor: pointer; font-size: 10px; }
|
||||||
|
.productGrid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 18px; }
|
||||||
|
.productCard { overflow: hidden; border: 1px solid var(--line); border-radius: 13px; background: #fff; transition: transform .2s,box-shadow .2s; }
|
||||||
|
.productCard:hover { transform: translateY(-4px); box-shadow: 0 15px 35px rgba(41,31,78,.1); }
|
||||||
|
.productImage { position: relative; height: 310px; background-image: url('/anime/star-echoes-merch.png'); background-repeat: no-repeat; background-size: 300% 100%; }
|
||||||
|
.productImage.left { background-position: left center; }
|
||||||
|
.productImage.center { background-position: center center; }
|
||||||
|
.productImage.right { background-position: right center; }
|
||||||
|
.productImage > span { position: absolute; left: 13px; top: 13px; padding: 5px 9px; border-radius: 4px; background: rgba(26,22,50,.84); color: #fff; font-size: 8px; letter-spacing: 1px; }
|
||||||
|
.productImage > button { position: absolute; right: 13px; top: 13px; width: 31px; height: 31px; border: 0; border-radius: 50%; background: rgba(255,255,255,.86); color: #594c86; cursor: pointer; font-size: 16px; backdrop-filter: blur(8px); }
|
||||||
|
.productBody { padding: 18px; }
|
||||||
|
.productBody > small { color: #9a96a5; font-size: 8px; letter-spacing: .6px; }
|
||||||
|
.productBody h3 { height: 34px; margin: 7px 0 8px; font-size: 12px; line-height: 1.5; }
|
||||||
|
.productRating { display: flex; justify-content: space-between; }
|
||||||
|
.productRating span { color: #e39b34; font-size: 8px; }
|
||||||
|
.productRating small { color: #aaa7b0; font-size: 8px; }
|
||||||
|
.productFooter { display: flex; align-items: flex-end; justify-content: space-between; margin-top: 18px; padding-top: 15px; border-top: 1px solid #f0eef3; }
|
||||||
|
.productFooter p { margin: 0; }
|
||||||
|
.productFooter strong { color: #6950dc; font-family: Georgia,serif; font-size: 18px; }
|
||||||
|
.productFooter del { margin-left: 7px; color: #b7b3bd; font-size: 8px; }
|
||||||
|
.productFooter button { height: 32px; padding: 0 14px; border: 0; border-radius: 17px; background: #1d1b31; color: #fff; cursor: pointer; font-size: 9px; }
|
||||||
|
.productFooter button:hover { background: var(--purple); }
|
||||||
|
|
||||||
|
.reviewSummary { display: grid; grid-template-columns: 210px 1fr 1.15fr; align-items: center; min-height: 165px; padding: 24px 34px; border: 1px solid var(--line); border-radius: 14px; background: #fff; }
|
||||||
|
.scoreBlock { display: flex; flex-direction: column; align-items: center; border-right: 1px solid #eeecf1; }
|
||||||
|
.scoreBlock strong { font-family: Georgia,serif; font-size: 46px; font-weight: 500; }
|
||||||
|
.scoreBlock span { margin: 4px 0; color: #f4b94c; font-size: 11px; letter-spacing: 2px; }
|
||||||
|
.scoreBlock small { color: #a3a0aa; font-size: 8px; }
|
||||||
|
.distribution { padding: 0 42px; border-right: 1px solid #eeecf1; }
|
||||||
|
.distribution > div { display: grid; grid-template-columns: 26px 1fr 26px; align-items: center; gap: 8px; margin: 6px 0; color: #aaa6b1; font-size: 8px; }
|
||||||
|
.distribution i { height: 4px; overflow: hidden; border-radius: 3px; background: #eeeaf3; }
|
||||||
|
.distribution b { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#7c5cff,#b191ff); }
|
||||||
|
.reviewHighlights { display: grid; grid-template-columns: repeat(3,1fr); text-align: center; }
|
||||||
|
.reviewHighlights > div { border-right: 1px solid #f0eef3; }
|
||||||
|
.reviewHighlights > div:last-child { border: 0; }
|
||||||
|
.reviewHighlights strong { display: block; font-family: Georgia,serif; font-size: 21px; font-weight: 500; }
|
||||||
|
.reviewHighlights small { color: #a19da8; font-size: 8px; }
|
||||||
|
.commentLayout { display: grid; grid-template-columns: minmax(0,1fr) 330px; align-items: start; gap: 25px; margin-top: 20px; }
|
||||||
|
.commentMain { border: 1px solid var(--line); border-radius: 14px; background: #fff; }
|
||||||
|
.commentToolbar { height: 63px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #efedf2; }
|
||||||
|
.commentToolbar button { padding: 8px 11px; border: 0; border-radius: 5px; background: transparent; color: #9995a3; cursor: pointer; font-size: 10px; }
|
||||||
|
.commentToolbar .sortActive { background: var(--purple-soft); color: #6854c1; font-weight: 700; }
|
||||||
|
.commentToolbar > span { color: #aaa7b0; font-size: 8px; }
|
||||||
|
.commentList { padding: 0 24px; }
|
||||||
|
.commentCard { display: flex; gap: 13px; padding: 25px 0; border-bottom: 1px solid #efedf2; }
|
||||||
|
.commentCard:last-child { border-bottom: 0; }
|
||||||
|
.commentAvatar { width: 37px; height: 37px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; font-size: 10px; font-weight: 800; }
|
||||||
|
.violet { background: #e9e1ff; color: #6d50c4; }
|
||||||
|
.blue { background: #dfeeff; color: #3975ae; }
|
||||||
|
.coral { background: #ffe5e2; color: #bd5f55; }
|
||||||
|
.green { background: #dff5ec; color: #318669; }
|
||||||
|
.commentBody { min-width: 0; flex: 1; }
|
||||||
|
.commentBody > header { display: flex; justify-content: space-between; gap: 15px; }
|
||||||
|
.commentBody header > div { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.commentBody header strong { font-size: 10px; }
|
||||||
|
.commentBody header span { color: #f0ad38; font-size: 7px; letter-spacing: 1px; }
|
||||||
|
.commentBody header span i { color: #ddd9e2; font-style: normal; }
|
||||||
|
.commentBody header small { color: #aaa7b1; font-size: 8px; }
|
||||||
|
.commentBody > p { margin: 12px 0; color: #5e5c68; font-size: 11px; line-height: 1.85; }
|
||||||
|
.commentActions { display: flex; gap: 4px; }
|
||||||
|
.commentActions button { padding: 4px 8px; border: 0; border-radius: 5px; background: transparent; color: #a29eaa; cursor: pointer; font-size: 8px; }
|
||||||
|
.commentActions button:hover { background: #f5f2fb; color: #6655ad; }
|
||||||
|
.commentActions .liked { background: #f0ebff; color: #6c52dd; }
|
||||||
|
.reply { display: flex; gap: 9px; margin-top: 14px; padding: 12px; border-radius: 7px; background: #f8f7fa; }
|
||||||
|
.reply > span { width: 25px; height: 25px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #e6eeff; color: #5176b4; font-size: 8px; }
|
||||||
|
.reply p { margin: 0; color: #74717c; font-size: 9px; line-height: 1.6; }
|
||||||
|
.reply p strong { margin-right: 8px; color: #393744; }
|
||||||
|
.reply small { display: block; margin-top: 4px; color: #aaa6b0; font-size: 7px; }
|
||||||
|
.composer { position: sticky; top: 84px; padding: 27px; border: 1px solid var(--line); border-radius: 14px; background: #fff; box-shadow: 0 12px 35px rgba(38,27,73,.05); }
|
||||||
|
.composerEyebrow { color: var(--purple); font-size: 7px; font-weight: 800; letter-spacing: 1.6px; }
|
||||||
|
.composer h3 { margin: 7px 0 5px; font-size: 16px; }
|
||||||
|
.composer > p { margin: 0 0 20px; color: #9b98a4; font-size: 8px; }
|
||||||
|
.scorePicker { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
|
.scorePicker > span { color: #777381; font-size: 9px; }
|
||||||
|
.scorePicker button { padding: 1px; border: 0; background: none; color: #dcd8e0; cursor: pointer; font-size: 15px; }
|
||||||
|
.scorePicker .starOn { color: #f1b243; }
|
||||||
|
.composer textarea { width: 100%; height: 125px; resize: none; padding: 12px; border: 1px solid #e4e1e9; border-radius: 8px; outline: none; background: #fbfafc; color: #4c4956; font-size: 10px; line-height: 1.7; }
|
||||||
|
.composer textarea:focus { border-color: #a997ed; box-shadow: 0 0 0 3px rgba(124,92,255,.08); }
|
||||||
|
.composerMeta { display: flex; justify-content: space-between; margin: 8px 0 16px; color: #aaa7b1; font-size: 8px; }
|
||||||
|
.composerMeta input { width: 11px; height: 11px; accent-color: var(--purple); vertical-align: middle; }
|
||||||
|
.publishButton { width: 100%; height: 38px; border: 0; border-radius: 7px; background: linear-gradient(90deg,#7053e2,#8b69ee); color: #fff; cursor: pointer; font-size: 10px; font-weight: 700; box-shadow: 0 8px 18px rgba(112,83,226,.2); }
|
||||||
|
.composer > small { display: block; margin-top: 10px; color: #b0acb5; font-size: 7px; text-align: center; }
|
||||||
|
|
||||||
|
.footer { min-height: 120px; display: flex; align-items: center; justify-content: space-between; padding: 0 max(28px,calc((100vw - 1240px) / 2)); background: #111020; color: #fff; }
|
||||||
|
.footer > div { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.footer p { display: flex; flex-direction: column; gap: 3px; margin: 0; }
|
||||||
|
.footer p strong { font-size: 9px; letter-spacing: 1.5px; }
|
||||||
|
.footer p small { color: rgba(255,255,255,.38); font-size: 8px; }
|
||||||
|
.footer a { color: rgba(255,255,255,.56); font-size: 9px; text-decoration: none; }
|
||||||
|
.toast { position: fixed; left: 50%; bottom: 28px; z-index: 50; display: flex; align-items: center; gap: 9px; padding: 11px 17px; border: 1px solid rgba(255,255,255,.12); border-radius: 24px; background: rgba(20,18,38,.93); color: #fff; box-shadow: 0 12px 30px rgba(14,10,32,.25); font-size: 10px; backdrop-filter: blur(12px); animation: toastIn .22s ease both; }
|
||||||
|
.toast span { width: 18px; height: 18px; display: grid; place-items: center; border-radius: 50%; background: #7c5cff; font-size: 8px; }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.header { gap: 20px; }
|
||||||
|
.primaryNav { display: none; }
|
||||||
|
.hero { height: 690px; }
|
||||||
|
.heroImage { background-position: 57% center; }
|
||||||
|
.heroCopy { width: 70vw; margin-top: 65px; }
|
||||||
|
.heroRating { display: none; }
|
||||||
|
.overviewGrid { grid-template-columns: 1fr; }
|
||||||
|
.infoCard { display: grid; grid-template-columns: 1fr 1.2fr; gap: 20px; }
|
||||||
|
.infoCard h3 { grid-column: 1/-1; margin-bottom: 0; }
|
||||||
|
.episodeGrid { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||||
|
.productImage { height: 240px; }
|
||||||
|
.reviewSummary { grid-template-columns: 170px 1fr; }
|
||||||
|
.reviewHighlights { grid-column: 1/-1; margin-top: 24px; padding-top: 20px; border-top: 1px solid #efedf2; }
|
||||||
|
.distribution { border-right: 0; }
|
||||||
|
.commentLayout { grid-template-columns: 1fr; }
|
||||||
|
.composer { position: static; order: -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.header { height: 64px; padding: 0 18px; }
|
||||||
|
.logo small { display: none; }
|
||||||
|
.logo strong { font-size: 10px; letter-spacing: 2px; }
|
||||||
|
.headerActions > button:first-child, .headerAvatar { display: none; }
|
||||||
|
.hero { height: 700px; }
|
||||||
|
.heroImage { background-position: 64% center; opacity: .85; }
|
||||||
|
.heroShade { background: linear-gradient(0deg,#080b19 0%,rgba(7,9,24,.72) 50%,rgba(5,7,20,.26) 100%); }
|
||||||
|
.heroContent { width: calc(100% - 36px); padding-top: 88px; }
|
||||||
|
.breadcrumb { font-size: 8px; }
|
||||||
|
.heroCopy { width: 100%; margin-top: 220px; }
|
||||||
|
.hero h1 { font-size: 49px; letter-spacing: 5px; }
|
||||||
|
.heroSubtitle { margin: 12px 0; font-size: 12px; }
|
||||||
|
.statusLine { margin-bottom: 12px; }
|
||||||
|
.statusLine > span:nth-last-child(-n+2) { display: none; }
|
||||||
|
.heroButtons { margin-top: 20px; }
|
||||||
|
.heroMetrics { left: 0; right: 0; bottom: 25px; justify-content: space-between; }
|
||||||
|
.heroMetrics > div { min-width: 0; margin: 0; padding: 0 20px 0 0; }
|
||||||
|
.heroMetrics strong { font-size: 16px; }
|
||||||
|
.sectionNav { overflow-x: auto; }
|
||||||
|
.sectionNav > div { width: max-content; margin: 0; padding: 0 18px; gap: 24px; }
|
||||||
|
.content { width: calc(100% - 32px); padding-bottom: 50px; }
|
||||||
|
.anchorSection { padding-top: 52px; }
|
||||||
|
.sectionHeading { margin-bottom: 20px; }
|
||||||
|
.sectionHeading h2 { font-size: 24px; }
|
||||||
|
.sectionHeading > p { display: none; }
|
||||||
|
.synopsisCard { grid-template-columns: 1fr; }
|
||||||
|
.posterCrop { min-height: 300px; background-position: 62% center; background-size: cover; }
|
||||||
|
.synopsisBody { padding: 29px 24px; }
|
||||||
|
.synopsisBody h3 { font-size: 20px; }
|
||||||
|
.infoCard { display: block; padding: 24px; }
|
||||||
|
.infoCard h3 { margin-bottom: 18px; }
|
||||||
|
.episodeFeature { grid-template-columns: 1fr; }
|
||||||
|
.episodeVisual { min-height: 190px; }
|
||||||
|
.episodeFeature > div:last-child { padding: 25px; }
|
||||||
|
.episodeGrid { grid-template-columns: 1fr; }
|
||||||
|
.productBanner { padding: 25px; }
|
||||||
|
.productBanner button { display: none; }
|
||||||
|
.productGrid { grid-template-columns: 1fr; }
|
||||||
|
.productImage { height: 340px; }
|
||||||
|
.reviewSummary { grid-template-columns: 1fr; padding: 25px; }
|
||||||
|
.scoreBlock { padding-bottom: 20px; border-right: 0; border-bottom: 1px solid #eeecf1; }
|
||||||
|
.distribution { padding: 20px 0 0; }
|
||||||
|
.reviewHighlights { gap: 0; }
|
||||||
|
.commentToolbar { padding: 0 14px; }
|
||||||
|
.commentToolbar > span { display: none; }
|
||||||
|
.commentList { padding: 0 16px; }
|
||||||
|
.commentCard { gap: 10px; }
|
||||||
|
.commentBody > header { flex-direction: column; gap: 5px; }
|
||||||
|
.composer { padding: 23px; }
|
||||||
|
.footer { min-height: 105px; padding: 0 20px; }
|
||||||
|
.footer a { display: none; }
|
||||||
|
}
|
||||||
300
app/anime/page.tsx
Normal file
300
app/anime/page.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import animeData from "../data/anime-detail.json";
|
||||||
|
import styles from "./AnimeDetail.module.css";
|
||||||
|
|
||||||
|
type Episode = (typeof animeData.episodes)[number];
|
||||||
|
type Product = (typeof animeData.products)[number];
|
||||||
|
type Reply = { id: number; author: string; initial: string; content: string; time: string; likes: number };
|
||||||
|
type Comment = {
|
||||||
|
id: number;
|
||||||
|
author: string;
|
||||||
|
initial: string;
|
||||||
|
tone: string;
|
||||||
|
score: number;
|
||||||
|
time: string;
|
||||||
|
episode: string;
|
||||||
|
content: string;
|
||||||
|
likes: number;
|
||||||
|
spoiler: boolean;
|
||||||
|
replies: Reply[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const work = animeData.work;
|
||||||
|
|
||||||
|
export default function AnimeDetailPage() {
|
||||||
|
const [favorite, setFavorite] = useState(false);
|
||||||
|
const [cartCount, setCartCount] = useState(0);
|
||||||
|
const [selectedEpisode, setSelectedEpisode] = useState(2);
|
||||||
|
const [comments, setComments] = useState<Comment[]>(animeData.comments as Comment[]);
|
||||||
|
const [likedComments, setLikedComments] = useState<number[]>([]);
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
const [commentScore, setCommentScore] = useState(5);
|
||||||
|
const [sort, setSort] = useState<"hot" | "new">("hot");
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
const [activeSection, setActiveSection] = useState("overview");
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setFavorite(window.localStorage.getItem("star-echoes-favorite") === "1");
|
||||||
|
setCartCount(Number(window.localStorage.getItem("star-echoes-cart") || 0));
|
||||||
|
const savedComments = window.localStorage.getItem("star-echoes-comments");
|
||||||
|
const savedLikes = window.localStorage.getItem("star-echoes-likes");
|
||||||
|
if (savedComments) setComments(JSON.parse(savedComments));
|
||||||
|
if (savedLikes) setLikedComments(JSON.parse(savedLikes));
|
||||||
|
} catch {
|
||||||
|
// Invalid demo cache falls back to the bundled JSON data.
|
||||||
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
|
window.localStorage.setItem("star-echoes-favorite", favorite ? "1" : "0");
|
||||||
|
window.localStorage.setItem("star-echoes-cart", String(cartCount));
|
||||||
|
window.localStorage.setItem("star-echoes-comments", JSON.stringify(comments));
|
||||||
|
window.localStorage.setItem("star-echoes-likes", JSON.stringify(likedComments));
|
||||||
|
}, [favorite, cartCount, comments, likedComments, hydrated]);
|
||||||
|
|
||||||
|
const visibleComments = useMemo(
|
||||||
|
() => [...comments].sort((a, b) => (sort === "hot" ? b.likes - a.likes : b.id - a.id)),
|
||||||
|
[comments, sort],
|
||||||
|
);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const jumpTo = (id: string) => {
|
||||||
|
setActiveSection(id);
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const playEpisode = (episode: Episode) => {
|
||||||
|
if (!episode.released) return notify("该剧集尚未更新,已为你开启更新提醒");
|
||||||
|
setSelectedEpisode(episode.id);
|
||||||
|
notify(`正在播放:第 ${episode.id} 话 ${episode.title}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addProduct = (product: Product) => {
|
||||||
|
setCartCount((count) => count + 1);
|
||||||
|
notify(`已将「${product.name}」加入购物车`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishComment = () => {
|
||||||
|
if (commentText.trim().length < 8) return notify("评论至少需要 8 个字");
|
||||||
|
const newComment: Comment = {
|
||||||
|
id: Date.now(),
|
||||||
|
author: "林知夏",
|
||||||
|
initial: "林",
|
||||||
|
tone: "blue",
|
||||||
|
score: commentScore,
|
||||||
|
time: "刚刚",
|
||||||
|
episode: `看到第 ${selectedEpisode} 话`,
|
||||||
|
content: commentText.trim(),
|
||||||
|
likes: 0,
|
||||||
|
spoiler: false,
|
||||||
|
replies: [],
|
||||||
|
};
|
||||||
|
setComments((current) => [newComment, ...current]);
|
||||||
|
setCommentText("");
|
||||||
|
setSort("new");
|
||||||
|
notify("评论发布成功,已保存在当前浏览器");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLike = (id: number) => {
|
||||||
|
setLikedComments((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<Link href="/" className={styles.logo} aria-label="返回模板首页">
|
||||||
|
<span className={styles.logoMark}>S</span>
|
||||||
|
<span><strong>STELLAR</strong><small>ANIME ARCHIVE</small></span>
|
||||||
|
</Link>
|
||||||
|
<nav className={styles.primaryNav} aria-label="主导航">
|
||||||
|
<button className={styles.currentNav} onClick={() => jumpTo("overview")}>作品</button>
|
||||||
|
<button onClick={() => jumpTo("episodes")}>剧集</button>
|
||||||
|
<button onClick={() => jumpTo("products")}>周边</button>
|
||||||
|
<button onClick={() => jumpTo("comments")}>社区</button>
|
||||||
|
</nav>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button aria-label="搜索" onClick={() => notify("搜索面板为模板交互占位")}>⌕</button>
|
||||||
|
<button className={styles.cartButton} aria-label={`购物车,共 ${cartCount} 件商品`} onClick={() => notify(`购物车中有 ${cartCount} 件商品`)}>
|
||||||
|
◇<span>{cartCount}</span>
|
||||||
|
</button>
|
||||||
|
<span className={styles.headerAvatar}>林</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.hero} aria-labelledby="work-title">
|
||||||
|
<div className={styles.heroImage} />
|
||||||
|
<div className={styles.heroShade} />
|
||||||
|
<div className={styles.heroContent}>
|
||||||
|
<div className={styles.breadcrumb}><Link href="/">模板首页</Link><span>/</span><span>动漫作品</span><span>/</span><strong>星海回响</strong></div>
|
||||||
|
<div className={styles.heroCopy}>
|
||||||
|
<div className={styles.statusLine}>
|
||||||
|
<span className={styles.onAir}><i />{work.status}</span>
|
||||||
|
<span>{work.year}</span><span>{work.region}</span><span>{work.age}</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.englishTitle}>{work.titleEn}</p>
|
||||||
|
<h1 id="work-title">{work.title}</h1>
|
||||||
|
<p className={styles.heroSubtitle}>{work.subtitle}</p>
|
||||||
|
<div className={styles.tags}>{work.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
|
||||||
|
<div className={styles.heroButtons}>
|
||||||
|
<button className={styles.playButton} onClick={() => playEpisode(animeData.episodes[selectedEpisode - 1])}><span>▶</span>续看第 {selectedEpisode} 话</button>
|
||||||
|
<button className={favorite ? styles.favoritedButton : styles.favoriteButton} onClick={() => { setFavorite((value) => !value); notify(favorite ? "已取消追番" : "已加入追番列表"); }}>
|
||||||
|
{favorite ? "✓ 已追番" : "+ 追番"}
|
||||||
|
</button>
|
||||||
|
<button className={styles.roundButton} aria-label="分享" onClick={() => notify("分享链接已复制(演示)")}>↗</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className={styles.heroRating}>
|
||||||
|
<small>STELLAR 用户评分</small>
|
||||||
|
<div><strong>{work.rating}</strong><span><b>★★★★★</b><small>{work.ratingCount.toLocaleString()} 人评分</small></span></div>
|
||||||
|
<i />
|
||||||
|
<p><span>本季排名</span><b>{work.rank}</b></p>
|
||||||
|
</aside>
|
||||||
|
<div className={styles.heroMetrics}>
|
||||||
|
<div><small>总播放</small><strong>{work.watchCount}</strong></div>
|
||||||
|
<div><small>追番人数</small><strong>{work.followCount}</strong></div>
|
||||||
|
<div><small>当前更新</small><strong>{work.updatedEpisode}<em> / {work.episodes}</em></strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav className={styles.sectionNav} aria-label="页面内容导航">
|
||||||
|
<div>
|
||||||
|
{[["overview", "作品详情"], ["episodes", `剧集 ${work.updatedEpisode}/${work.episodes}`], ["products", "官方周边"], ["comments", `评论 ${comments.length}`]].map(([id, label]) => (
|
||||||
|
<button key={id} className={activeSection === id ? styles.activeTab : ""} onClick={() => jumpTo(id)}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className={styles.content}>
|
||||||
|
<section id="overview" className={styles.anchorSection}>
|
||||||
|
<div className={styles.sectionHeading}>
|
||||||
|
<div><span>ABOUT THE SERIES</span><h2>作品详情</h2></div>
|
||||||
|
<p>{work.copyright}</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewGrid}>
|
||||||
|
<article className={styles.synopsisCard}>
|
||||||
|
<div className={styles.posterCrop} role="img" aria-label="星海回响作品海报" />
|
||||||
|
<div className={styles.synopsisBody}>
|
||||||
|
<span className={styles.kicker}>STORY</span>
|
||||||
|
<h3>在没有星空的城市,<br />追寻一束跨越十二年的光。</h3>
|
||||||
|
<p>{work.description}</p>
|
||||||
|
<div className={styles.quote}>“我们不是为了找到答案才抬头,而是因为抬头之后,世界会变得不一样。”</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<aside className={styles.infoCard}>
|
||||||
|
<h3>作品信息</h3>
|
||||||
|
<dl>
|
||||||
|
<div><dt>动画制作</dt><dd>{work.studio}</dd></div>
|
||||||
|
<div><dt>监督</dt><dd>{work.director}</dd></div>
|
||||||
|
<div><dt>首播日期</dt><dd>{work.release}</dd></div>
|
||||||
|
<div><dt>更新安排</dt><dd>{work.schedule}</dd></div>
|
||||||
|
<div><dt>话数</dt><dd>全 {work.episodes} 话</dd></div>
|
||||||
|
<div><dt>内容分级</dt><dd>{work.age}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div className={styles.notice}><span>i</span><p><strong>更新提醒</strong>{work.announcement}</p></div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="episodes" className={styles.anchorSection}>
|
||||||
|
<div className={styles.sectionHeading}>
|
||||||
|
<div><span>EPISODE GUIDE</span><h2>剧集列表</h2></div>
|
||||||
|
<button className={styles.textButton} onClick={() => notify("已为你开启自动连播")}>自动连播 ○</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.episodeFeature}>
|
||||||
|
<div className={styles.episodeVisual}><span>正在观看</span><button onClick={() => playEpisode(animeData.episodes[selectedEpisode - 1])}>▶</button><small>第 {selectedEpisode} 话</small></div>
|
||||||
|
<div><span className={styles.kicker}>CONTINUE WATCHING</span><h3>第 {selectedEpisode} 话 {animeData.episodes[selectedEpisode - 1].title}</h3><p>{animeData.episodes[selectedEpisode - 1].summary}</p><div className={styles.watchProgress}><span><i style={{ width: `${animeData.episodes[selectedEpisode - 1].progress}%` }} /></span><small>{animeData.episodes[selectedEpisode - 1].progress}%</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.episodeGrid}>
|
||||||
|
{animeData.episodes.map((episode) => (
|
||||||
|
<button key={episode.id} className={`${styles.episodeCard} ${selectedEpisode === episode.id ? styles.selectedEpisode : ""} ${!episode.released ? styles.lockedEpisode : ""}`} onClick={() => playEpisode(episode)}>
|
||||||
|
<span className={styles.episodeNumber}>{String(episode.id).padStart(2, "0")}</span>
|
||||||
|
<span className={styles.episodeInfo}><strong>{episode.title}</strong><small>{episode.date} · {episode.duration}</small></span>
|
||||||
|
<i>{episode.released ? "▶" : "⌕"}</i>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="products" className={styles.anchorSection}>
|
||||||
|
<div className={styles.sectionHeading}>
|
||||||
|
<div><span>OFFICIAL MERCHANDISE</span><h2>官方周边</h2></div>
|
||||||
|
<button className={styles.textButton} onClick={() => notify("全部商品页为模板交互占位")}>查看全部商品 →</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.productBanner}><div><span>限时企划</span><h3>把星光,带回日常。</h3><p>动画官方收藏系列 · 满 299 元免运费</p></div><button onClick={() => jumpTo("products")}>浏览本期新品</button></div>
|
||||||
|
<div className={styles.productGrid}>
|
||||||
|
{animeData.products.map((product) => (
|
||||||
|
<article className={styles.productCard} key={product.id}>
|
||||||
|
<div className={`${styles.productImage} ${styles[product.imagePosition]}`}><span>{product.badge}</span><button aria-label={`收藏 ${product.name}`} onClick={() => notify(`已收藏「${product.name}」`)}>♡</button></div>
|
||||||
|
<div className={styles.productBody}>
|
||||||
|
<small>{product.category}</small><h3>{product.name}</h3>
|
||||||
|
<div className={styles.productRating}><span>★ {product.rating}</span><small>{product.stock}</small></div>
|
||||||
|
<div className={styles.productFooter}><p><strong>¥{product.price}</strong><del>¥{product.originalPrice}</del></p><button onClick={() => addProduct(product)}>加入购物车</button></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="comments" className={styles.anchorSection}>
|
||||||
|
<div className={styles.sectionHeading}>
|
||||||
|
<div><span>COMMUNITY REVIEWS</span><h2>观众评论</h2></div>
|
||||||
|
<p>友善交流,请勿发布未经标记的剧透内容</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.reviewSummary}>
|
||||||
|
<div className={styles.scoreBlock}><strong>{work.rating}</strong><span>★★★★★</span><small>{work.ratingCount.toLocaleString()} 人评分</small></div>
|
||||||
|
<div className={styles.distribution}>
|
||||||
|
{animeData.ratingDistribution.map((row) => <div key={row.stars}><span>{row.stars} 星</span><i><b style={{ width: `${row.percent}%` }} /></i><small>{row.percent}%</small></div>)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.reviewHighlights}><div><strong>96%</strong><small>愿意推荐</small></div><div><strong>8.8</strong><small>剧情表现</small></div><div><strong>9.6</strong><small>美术音乐</small></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.commentLayout}>
|
||||||
|
<div className={styles.commentMain}>
|
||||||
|
<div className={styles.commentToolbar}><div><button className={sort === "hot" ? styles.sortActive : ""} onClick={() => setSort("hot")}>热门评论</button><button className={sort === "new" ? styles.sortActive : ""} onClick={() => setSort("new")}>最新评论</button></div><span>共 {comments.length} 条精选评论</span></div>
|
||||||
|
<div className={styles.commentList}>
|
||||||
|
{visibleComments.map((comment) => <CommentItem key={comment.id} comment={comment} liked={likedComments.includes(comment.id)} onLike={() => toggleLike(comment.id)} onReply={() => notify("回复编辑器为模板交互占位")} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className={styles.composer}>
|
||||||
|
<span className={styles.composerEyebrow}>SHARE YOUR THOUGHTS</span><h3>写下你的观后感</h3><p>你正在评论《{work.title}》第 {selectedEpisode} 话</p>
|
||||||
|
<div className={styles.scorePicker}><span>我的评分</span><div>{[1, 2, 3, 4, 5].map((score) => <button key={score} className={score <= commentScore ? styles.starOn : ""} onClick={() => setCommentScore(score)} aria-label={`${score} 星`}>★</button>)}</div></div>
|
||||||
|
<textarea value={commentText} onChange={(event) => setCommentText(event.target.value)} maxLength={500} placeholder="聊聊最打动你的剧情、角色或画面……" />
|
||||||
|
<div className={styles.composerMeta}><label><input type="checkbox" /> 包含剧透</label><span>{commentText.length}/500</span></div>
|
||||||
|
<button className={styles.publishButton} onClick={publishComment}>发布评论</button>
|
||||||
|
<small>评论将仅保存在当前浏览器中,刷新后仍可查看。</small>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className={styles.footer}><div><span className={styles.logoMark}>S</span><p><strong>STELLAR ANIME ARCHIVE</strong><small>原创动漫详情页前端模板 · JSON 数据演示</small></p></div><Link href="/">返回 WBBR 模板首页 →</Link></footer>
|
||||||
|
{toast && <div className={styles.toast}><span>✓</span>{toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentItem({ comment, liked, onLike, onReply }: { comment: Comment; liked: boolean; onLike: () => void; onReply: () => void }) {
|
||||||
|
return (
|
||||||
|
<article className={styles.commentCard}>
|
||||||
|
<span className={`${styles.commentAvatar} ${styles[comment.tone]}`}>{comment.initial}</span>
|
||||||
|
<div className={styles.commentBody}>
|
||||||
|
<header><div><strong>{comment.author}</strong><span>{"★".repeat(comment.score)}<i>{"★".repeat(5 - comment.score)}</i></span></div><small>{comment.time} · {comment.episode}</small></header>
|
||||||
|
<p>{comment.content}</p>
|
||||||
|
<div className={styles.commentActions}><button className={liked ? styles.liked : ""} onClick={onLike}>♡ {comment.likes + (liked ? 1 : 0)}</button><button onClick={onReply}>回复</button><button>•••</button></div>
|
||||||
|
{comment.replies.map((reply) => <div className={styles.reply} key={reply.id}><span>{reply.initial}</span><p><strong>{reply.author}</strong>{reply.content}<small>{reply.time} ♡ {reply.likes}</small></p></div>)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
app/calendar/SeasonalCalendar.module.css
Normal file
14
app/calendar/SeasonalCalendar.module.css
Normal file
File diff suppressed because one or more lines are too long
222
app/calendar/page.tsx
Normal file
222
app/calendar/page.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CSSProperties, useMemo, useState } from "react";
|
||||||
|
import calendarData from "../data/seasonal-calendar.json";
|
||||||
|
import styles from "./SeasonalCalendar.module.css";
|
||||||
|
|
||||||
|
type CategoryId = "fruit" | "seafood" | "activity";
|
||||||
|
type Category = { id: CategoryId; name: string; icon: string; color: string; soft: string };
|
||||||
|
type CalendarEvent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: CategoryId;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
region: string;
|
||||||
|
bestTime: string;
|
||||||
|
summary: string;
|
||||||
|
tips: string;
|
||||||
|
};
|
||||||
|
type CalendarDay = { date: Date; key: string; inMonth: boolean };
|
||||||
|
|
||||||
|
const categories = calendarData.categories as Category[];
|
||||||
|
const events = calendarData.events as CalendarEvent[];
|
||||||
|
const eventPalette: Record<string, { color: string; soft: string }> = {
|
||||||
|
"fruit-peach": { color: "#df6548", soft: "#fff0e9" },
|
||||||
|
"fruit-grape": { color: "#9a6a31", soft: "#fbf1df" },
|
||||||
|
"fruit-fig": { color: "#a75278", soft: "#faedf3" },
|
||||||
|
"fruit-pear": { color: "#799536", soft: "#f0f6df" },
|
||||||
|
"seafood-crab": { color: "#267aa5", soft: "#e8f5fb" },
|
||||||
|
"seafood-urchin": { color: "#4f68b8", soft: "#edf1ff" },
|
||||||
|
"seafood-bass": { color: "#278b83", soft: "#e8f7f4" },
|
||||||
|
"seafood-shrimp": { color: "#46799c", soft: "#eaf3f8" },
|
||||||
|
"activity-buy": { color: "#6c57c8", soft: "#f0edff" },
|
||||||
|
"activity-menu": { color: "#a151b5", soft: "#f8ecfb" },
|
||||||
|
};
|
||||||
|
const eventImages: Record<string, string> = {
|
||||||
|
"fruit-peach": "/seasonal/honey-peach-pixel.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SeasonalCalendarPage() {
|
||||||
|
const initial = parseDate(calendarData.defaultDate);
|
||||||
|
const [month, setMonth] = useState(() => new Date(initial.getFullYear(), initial.getMonth(), 1));
|
||||||
|
const [selectedDate, setSelectedDate] = useState(calendarData.defaultDate);
|
||||||
|
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||||
|
const [enabledCategories, setEnabledCategories] = useState<Set<CategoryId>>(() => new Set(categories.map((item) => item.id)));
|
||||||
|
|
||||||
|
const days = useMemo(() => makeCalendarDays(month), [month]);
|
||||||
|
const visibleEvents = useMemo(() => events.filter((event) => enabledCategories.has(event.category)), [enabledCategories]);
|
||||||
|
const selectedEvents = useMemo(() => visibleEvents.filter((event) => event.start <= selectedDate && event.end >= selectedDate), [selectedDate, visibleEvents]);
|
||||||
|
const selectedEvent = selectedEventId ? events.find((event) => event.id === selectedEventId) ?? null : null;
|
||||||
|
const monthEvents = useMemo(() => {
|
||||||
|
const first = formatDate(new Date(month.getFullYear(), month.getMonth(), 1));
|
||||||
|
const last = formatDate(new Date(month.getFullYear(), month.getMonth() + 1, 0));
|
||||||
|
return visibleEvents.filter((event) => event.start <= last && event.end >= first);
|
||||||
|
}, [month, visibleEvents]);
|
||||||
|
|
||||||
|
const changeMonth = (offset: number) => {
|
||||||
|
const next = new Date(month.getFullYear(), month.getMonth() + offset, 1);
|
||||||
|
setMonth(next);
|
||||||
|
setSelectedDate(formatDate(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectDay = (day: CalendarDay) => {
|
||||||
|
setSelectedDate(day.key);
|
||||||
|
if (!day.inMonth) setMonth(new Date(day.date.getFullYear(), day.date.getMonth(), 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategory = (id: CategoryId) => setEnabledCategories((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
if (selectedEvent?.category === id) setSelectedEventId(null);
|
||||||
|
} else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const goToday = () => {
|
||||||
|
const today = new Date();
|
||||||
|
setMonth(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||||
|
setSelectedDate(formatDate(today));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.topbar}>
|
||||||
|
<Link href="/" className={styles.brand}><span>历</span><div><strong>Seasonal Calendar</strong><small>WBBR STYLE TEMPLATE</small></div></Link>
|
||||||
|
<nav><Link href="/">模板主页</Link><a href="#calendar">日历视图</a><a href="#season-list">时令清单</a></nav>
|
||||||
|
<div className={styles.topStatus}><i />本地 JSON 演示</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<div>
|
||||||
|
<p>SEASONAL SCHEDULE</p>
|
||||||
|
<h1>{calendarData.title}</h1>
|
||||||
|
<span>{calendarData.subtitle}。选择任意日期,即可查看当天正在进行的全部事项。</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroStats}>
|
||||||
|
<article><small>本月事项</small><strong>{monthEvents.length}</strong><span>跨日期事件</span></article>
|
||||||
|
<article><small>选中日期</small><strong>{selectedEvents.length}</strong><span>当天有效事项</span></article>
|
||||||
|
<article><small>数据分类</small><strong>{enabledCategories.size}</strong><span>当前显示分类</span></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.workspace} id="calendar">
|
||||||
|
<div className={styles.calendarPanel}>
|
||||||
|
<div className={styles.calendarToolbar}>
|
||||||
|
<div className={styles.monthControl}>
|
||||||
|
<button onClick={() => changeMonth(-1)} aria-label="上个月">‹</button>
|
||||||
|
<div><strong>{month.getFullYear()}年 {month.getMonth() + 1}月</strong><small>{month.toLocaleDateString("zh-CN", { month: "long", year: "numeric" })}</small></div>
|
||||||
|
<button onClick={() => changeMonth(1)} aria-label="下个月">›</button>
|
||||||
|
<button className={styles.todayButton} onClick={goToday}>今天</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.categoryFilters}>
|
||||||
|
{categories.map((category) => <button key={category.id} className={enabledCategories.has(category.id) ? styles.filterActive : ""} onClick={() => toggleCategory(category.id)} style={{ "--category": category.color, "--category-soft": category.soft } as CSSProperties}><i />{category.name}<b>{events.filter((event) => event.category === category.id).length}</b></button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEvent && <div className={styles.highlightNotice} style={eventStyle(selectedEvent)}>
|
||||||
|
<i />
|
||||||
|
<div><small>正在突出显示</small><strong>{selectedEvent.title}</strong><span>{shortDate(selectedEvent.start)} — {shortDate(selectedEvent.end)},覆盖日期已统一着色</span></div>
|
||||||
|
<button onClick={() => setSelectedEventId(null)}>清除突出 ×</button>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<div className={styles.weekHeader}>{["周一", "周二", "周三", "周四", "周五", "周六", "周日"].map((day) => <span key={day}>{day}</span>)}</div>
|
||||||
|
<div className={styles.calendarGrid}>
|
||||||
|
{days.map((day) => {
|
||||||
|
const dayEvents = visibleEvents.filter((event) => event.start <= day.key && event.end >= day.key);
|
||||||
|
const isToday = day.key === formatDate(new Date());
|
||||||
|
const isEventRange = Boolean(selectedEvent && selectedEvent.start <= day.key && selectedEvent.end >= day.key);
|
||||||
|
return <div key={day.key} className={`${styles.dayCell} ${day.inMonth ? "" : styles.outsideMonth} ${selectedDate === day.key ? styles.selectedDay : ""} ${isEventRange ? styles.eventRangeDay : ""} ${isEventRange && selectedDate === day.key ? styles.selectedRangeDay : ""}`} onClick={() => selectDay(day)} style={isEventRange && selectedEvent ? eventStyle(selectedEvent) : undefined}>
|
||||||
|
<button className={`${styles.dayNumber} ${isToday ? styles.today : ""}`} onClick={(clickEvent) => { clickEvent.stopPropagation(); selectDay(day); }} aria-label={`${day.key},${dayEvents.length}项事件`}>{day.date.getDate()}</button>
|
||||||
|
<div className={styles.dayEvents}>
|
||||||
|
{dayEvents.slice(0, 3).map((event) => {
|
||||||
|
return <button type="button" key={event.id} className={`${event.start < day.key ? styles.continuesBefore : ""} ${event.end > day.key ? styles.continuesAfter : ""} ${selectedEventId === event.id ? styles.activeEventBar : ""}`} style={eventStyle(event)} onClick={(clickEvent) => { clickEvent.stopPropagation(); setSelectedDate(day.key); setSelectedEventId(event.id); }}>{event.title}</button>;
|
||||||
|
})}
|
||||||
|
{dayEvents.length > 3 && <b>还有 {dayEvents.length - 3} 项</b>}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className={styles.detailPanel}>
|
||||||
|
<header><div><small>SELECTED DATE</small><h2>{formatChineseDate(selectedDate)}</h2><span>{weekdayName(selectedDate)} · {selectedEvents.length} 项正在进行</span></div><b>{selectedDate.slice(8)}</b></header>
|
||||||
|
<div className={styles.detailList}>
|
||||||
|
{selectedEvents.map((event) => {
|
||||||
|
const category = categories.find((item) => item.id === event.category)!;
|
||||||
|
return <article key={event.id} className={selectedEventId === event.id ? styles.activeEventCard : ""} style={eventStyle(event)} role="button" tabIndex={0} onClick={() => setSelectedEventId(event.id)} onKeyDown={(keyEvent) => { if (keyEvent.key === "Enter" || keyEvent.key === " ") setSelectedEventId(event.id); }}>
|
||||||
|
{eventImages[event.id] && <img className={styles.eventImage} src={eventImages[event.id]} alt={`${event.title}像素插图`} />}
|
||||||
|
<div className={styles.eventHead}><span>{category.icon}</span><div><small>{category.name} · {event.region}</small><h3>{event.title}</h3></div></div>
|
||||||
|
<p>{event.summary}</p>
|
||||||
|
<dl><div><dt>持续时间</dt><dd>{shortDate(event.start)} — {shortDate(event.end)}</dd></div><div><dt>推荐时间</dt><dd>{event.bestTime}</dd></div></dl>
|
||||||
|
<footer><span>提示</span>{event.tips}</footer>
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
{selectedEvents.length === 0 && <div className={styles.emptyDay}><span>○</span><h3>这一天暂无记录</h3><p>可切换分类,或选择带有彩色事项条的日期。</p></div>}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.seasonSection} id="season-list">
|
||||||
|
<div className={styles.sectionTitle}><div><p>SEASON OVERVIEW</p><h2>本月时令与事项清单</h2></div><span>共 {monthEvents.length} 项 · 当前筛选结果</span></div>
|
||||||
|
<div className={styles.seasonGrid}>
|
||||||
|
{monthEvents.map((event) => {
|
||||||
|
const category = categories.find((item) => item.id === event.category)!;
|
||||||
|
return <button key={event.id} className={selectedEventId === event.id ? styles.activeSeasonEvent : ""} onClick={() => {
|
||||||
|
const start = parseDate(event.start);
|
||||||
|
const target = start < new Date(month.getFullYear(), month.getMonth(), 1) ? formatDate(new Date(month.getFullYear(), month.getMonth(), 1)) : event.start;
|
||||||
|
setSelectedDate(target);
|
||||||
|
setSelectedEventId(event.id);
|
||||||
|
document.getElementById("calendar")?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}} style={eventStyle(event)}>
|
||||||
|
{eventImages[event.id] ? <img className={styles.seasonImage} src={eventImages[event.id]} alt={`${event.title}像素插图`} /> : <span>{category.icon}</span>}<div><small>{category.name} · {event.region}</small><strong>{event.title}</strong><p>{event.summary}</p><time>{shortDate(event.start)} — {shortDate(event.end)}</time></div><b>{selectedEventId === event.id ? "正在突出 ✓" : "突出日期 ↗"}</b>
|
||||||
|
</button>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value: string) {
|
||||||
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCalendarDays(month: Date): CalendarDay[] {
|
||||||
|
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||||
|
const mondayOffset = (first.getDay() + 6) % 7;
|
||||||
|
const start = new Date(first.getFullYear(), first.getMonth(), 1 - mondayOffset);
|
||||||
|
return Array.from({ length: 42 }, (_, index) => {
|
||||||
|
const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + index);
|
||||||
|
return { date, key: formatDate(date), inMonth: date.getMonth() === month.getMonth() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChineseDate(value: string) {
|
||||||
|
const date = parseDate(value);
|
||||||
|
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayName(value: string) {
|
||||||
|
return ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"][parseDate(value).getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(value: string) {
|
||||||
|
const [, month, day] = value.split("-");
|
||||||
|
return `${Number(month)}月${Number(day)}日`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventStyle(event: CalendarEvent) {
|
||||||
|
const fallback = categories.find((item) => item.id === event.category)!;
|
||||||
|
const palette = eventPalette[event.id] ?? fallback;
|
||||||
|
return { "--event-color": palette.color, "--event-soft": palette.soft } as CSSProperties;
|
||||||
|
}
|
||||||
1
app/character/CharacterDetail.module.css
Normal file
1
app/character/CharacterDetail.module.css
Normal file
File diff suppressed because one or more lines are too long
107
app/character/page.tsx
Normal file
107
app/character/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import data from "../data/content-templates.json";
|
||||||
|
import styles from "./CharacterDetail.module.css";
|
||||||
|
|
||||||
|
const character = data.character;
|
||||||
|
|
||||||
|
export default function CharacterDetailPage() {
|
||||||
|
const [favorite, setFavorite] = useState(false);
|
||||||
|
const [tags, setTags] = useState<string[]>(character.tags);
|
||||||
|
const [newTag, setNewTag] = useState("");
|
||||||
|
const [note, setNote] = useState("第 09 话需要加强她主动提出关闭穹顶照明的动机铺垫。\n与遥的冲突不能只停留在信息差,需要落到选择差异。 ");
|
||||||
|
const [editingNote, setEditingNote] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage.getItem("star-character-detail");
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
setFavorite(Boolean(parsed.favorite));
|
||||||
|
if (Array.isArray(parsed.tags)) setTags(parsed.tags);
|
||||||
|
if (typeof parsed.note === "string") setNote(parsed.note);
|
||||||
|
}
|
||||||
|
} catch { /* use JSON defaults */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem("star-character-detail", JSON.stringify({ favorite, tags, note }));
|
||||||
|
}, [favorite, tags, note]);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 2100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
const value = newTag.trim();
|
||||||
|
if (!value || tags.includes(value)) return;
|
||||||
|
setTags((current) => [...current, value]);
|
||||||
|
setNewTag("");
|
||||||
|
notify(`已添加标签「${value}」`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.topbar}>
|
||||||
|
<Link className={styles.brand} href="/"><span>S</span><strong>STELLAR<br /><small>CHARACTER DATABASE</small></strong></Link>
|
||||||
|
<nav><Link href="/simple-list">简单列表</Link><Link className={styles.active} href="/character">角色详情</Link><Link href="/markdown">Markdown 编辑器</Link></nav>
|
||||||
|
<div className={styles.headerActions}><button onClick={() => notify("搜索为模板交互占位")}>⌕</button><button onClick={() => notify("分享链接已复制(演示)")}>↗</button><span>林</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.cover}>
|
||||||
|
<div className={styles.coverImage} />
|
||||||
|
<div className={styles.coverShade} />
|
||||||
|
<div className={styles.coverInner}>
|
||||||
|
<div className={styles.breadcrumb}><Link href="/">模板首页</Link><span>/</span><Link href="/simple-list">角色资料库</Link><span>/</span><strong>{character.name}</strong></div>
|
||||||
|
<div className={styles.identity}>
|
||||||
|
<p>{character.code}</p><h1>{character.name}</h1><span>{character.nameEn}</span><blockquote>“{character.quote}”</blockquote>
|
||||||
|
</div>
|
||||||
|
<button className={favorite ? styles.following : styles.follow} onClick={() => { setFavorite((value) => !value); notify(favorite ? "已取消收藏" : "已收藏角色"); }}>{favorite ? "✓ 已收藏" : "+ 收藏角色"}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main className={styles.content}>
|
||||||
|
<aside className={styles.profileCard}>
|
||||||
|
<div className={styles.portrait} role="img" aria-label="天野澪角色形象"><span>主角</span></div>
|
||||||
|
<div className={styles.profileBody}>
|
||||||
|
<p>{character.role}</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>年龄</dt><dd>{character.age} 岁</dd></div><div><dt>生日</dt><dd>{character.birthday}</dd></div><div><dt>身高</dt><dd>{character.height}</dd></div><div><dt>声优</dt><dd>{character.voice}</dd></div><div><dt>所属</dt><dd>{character.affiliation}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<button onClick={() => notify("角色编辑表单为模板交互占位")}>编辑基础资料</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className={styles.mainColumn}>
|
||||||
|
<nav className={styles.tabs}>{[["overview","角色概览"],["setting","能力设定"],["timeline","剧情轨迹"]].map(([id,label]) => <button key={id} className={activeTab === id ? styles.tabActive : ""} onClick={() => setActiveTab(id)}>{label}</button>)}</nav>
|
||||||
|
|
||||||
|
{activeTab === "overview" && <>
|
||||||
|
<section className={styles.panel}><header><div><span>PROFILE</span><h2>角色简介</h2></div><button onClick={() => notify("编辑器为模板交互占位")}>编辑</button></header><p className={styles.bio}>{character.bio}</p><div className={styles.keyFacts}><div><small>核心动机</small><strong>寻找十二年前讯号的来源</strong></div><div><small>外部目标</small><strong>让城市重新看见星空</strong></div><div><small>内在冲突</small><strong>理性判断与情感信任</strong></div></div></section>
|
||||||
|
<section className={styles.panel}><header><div><span>RELATIONSHIPS</span><h2>人物关系</h2></div><button onClick={() => notify("关系图为模板交互占位")}>查看关系图</button></header><div className={styles.relations}>{character.relations.map((item) => <article key={item.name}><span className={styles[item.tone]}>{item.initial}</span><p><strong>{item.name}</strong><small>{item.relation}</small></p><button>→</button></article>)}</div></section>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{activeTab === "setting" && <>
|
||||||
|
<section className={styles.panel}><header><div><span>PERSONAL ATTRIBUTES</span><h2>人物能力值</h2></div><small>设定集 v2.4</small></header><div className={styles.traits}>{character.traits.map((trait) => <div key={trait.label}><p><span>{trait.label}</span><strong>{trait.value}</strong></p><i><b style={{width:`${trait.value}%`}} /></i></div>)}</div></section>
|
||||||
|
<section className={styles.panel}><header><div><span>SPECIAL SKILLS</span><h2>技能与专长</h2></div></header><div className={styles.skills}>{character.skills.map((skill) => <article key={skill.name}><span>{skill.level}</span><p><strong>{skill.name}</strong><small>{skill.description}</small></p></article>)}</div></section>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{activeTab === "timeline" && <section className={styles.panel}><header><div><span>STORY TIMELINE</span><h2>剧情轨迹</h2></div><button onClick={() => notify("已切换为按剧集排序")}>按剧集排序</button></header><div className={styles.timeline}>{character.timeline.map((item,index) => <article key={item.episode}><i>{index+1}</i><div><span>{item.episode} · {item.type}</span><strong>{item.title}</strong></div><button>查看剧集 →</button></article>)}</div></section>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className={styles.sideColumn}>
|
||||||
|
<section className={styles.tagPanel}><header><div><span>TAGS</span><h2>角色标签</h2></div><small>{tags.length} 个</small></header><div className={styles.tags}>{tags.map((tag,index) => <span className={index < 2 ? styles.importantTag : ""} key={tag}>#{tag}<button onClick={() => setTags((current) => current.filter((item) => item !== tag))}>×</button></span>)}</div><form onSubmit={(event) => {event.preventDefault();addTag();}}><input value={newTag} onChange={(event) => setNewTag(event.target.value)} placeholder="输入新标签" /><button type="submit">添加</button></form><p>标签保存在当前浏览器,可用于筛选、归类和快速检索。</p></section>
|
||||||
|
|
||||||
|
<section className={styles.notePanel}><header><div><span>PRIVATE NOTE</span><h2>创作备注</h2></div><button onClick={() => { setEditingNote((value) => !value); if (editingNote) notify("备注已保存"); }}>{editingNote ? "保存" : "编辑"}</button></header>{editingNote ? <textarea value={note} onChange={(event) => setNote(event.target.value)} autoFocus /> : <p>{note}</p>}<small>仅当前设备可见 · 自动保存</small></section>
|
||||||
|
|
||||||
|
<section className={styles.versionPanel}><span>v2.4</span><div><strong>角色设定已同步</strong><small>最后更新:今天 14:32 · 林知夏</small></div><button onClick={() => notify("版本记录为模板交互占位")}>记录</button></section>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
app/data/anime-detail.json
Normal file
108
app/data/anime-detail.json
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"work": {
|
||||||
|
"title": "星海回响",
|
||||||
|
"titleEn": "ECHOES ACROSS THE STARS",
|
||||||
|
"subtitle": "当最后一颗人造星辰熄灭,他们决定把真正的星空带回城市。",
|
||||||
|
"status": "连载中",
|
||||||
|
"year": "2026",
|
||||||
|
"region": "原创动画",
|
||||||
|
"age": "13+",
|
||||||
|
"episodes": 12,
|
||||||
|
"updatedEpisode": 8,
|
||||||
|
"rating": 9.3,
|
||||||
|
"ratingCount": 18342,
|
||||||
|
"watchCount": "126.8万",
|
||||||
|
"followCount": "48.2万",
|
||||||
|
"rank": "本季口碑榜 No.2",
|
||||||
|
"tags": ["科幻", "青春", "治愈", "原创"],
|
||||||
|
"studio": "Lumen Frame",
|
||||||
|
"director": "川原澄",
|
||||||
|
"release": "2026-07-04",
|
||||||
|
"schedule": "每周六 20:00 更新",
|
||||||
|
"description": "海平面上升后的新港市,真正的星空被人造穹顶遮蔽。天文部少女澪在废弃观测站收到一段来自十二年前的星际讯号,并与转学生遥组成临时观测队。他们沿着讯号留下的坐标寻找失落的天文台,也逐渐发现城市穹顶背后被隐藏的秘密。",
|
||||||
|
"copyright": "© 2026 Project Star Echoes",
|
||||||
|
"announcement": "第 9 话将于本周六 20:00 准时更新"
|
||||||
|
},
|
||||||
|
"episodes": [
|
||||||
|
{ "id": 1, "title": "看不见星星的城市", "duration": "24:12", "date": "07-04", "progress": 100, "released": true, "summary": "澪在废弃天文台意外接收到未知讯号。" },
|
||||||
|
{ "id": 2, "title": "十二年前的坐标", "duration": "23:48", "date": "07-11", "progress": 42, "released": true, "summary": "遥辨认出讯号中属于旧港区的坐标。" },
|
||||||
|
{ "id": 3, "title": "雨停之前", "duration": "24:05", "date": "07-18", "progress": 0, "released": true, "summary": "两人在暴雨中寻找第一座地面信标。" },
|
||||||
|
{ "id": 4, "title": "潮汐电台", "duration": "23:56", "date": "07-25", "progress": 0, "released": true, "summary": "一段夜间广播连接起被遗忘的记忆。" },
|
||||||
|
{ "id": 5, "title": "光的背面", "duration": "24:20", "date": "08-01", "progress": 0, "released": true, "summary": "穹顶维护记录第一次出现人为删改痕迹。" },
|
||||||
|
{ "id": 6, "title": "无人观测站", "duration": "23:51", "date": "08-08", "progress": 0, "released": true, "summary": "观测队在海上风场发现第二台接收器。" },
|
||||||
|
{ "id": 7, "title": "零点之后", "duration": "24:03", "date": "08-15", "progress": 0, "released": true, "summary": "城市短暂停电,真正的银河显露了十一秒。" },
|
||||||
|
{ "id": 8, "title": "我们仰望的方向", "duration": "24:16", "date": "08-22", "progress": 0, "released": true, "summary": "澪决定公开观测数据,却面临意外阻力。" },
|
||||||
|
{ "id": 9, "title": "彗星抵达之夜", "duration": "--:--", "date": "本周六", "progress": 0, "released": false, "summary": "彗星将穿过穹顶开放窗口。" },
|
||||||
|
{ "id": 10, "title": "尚未命名", "duration": "--:--", "date": "待更新", "progress": 0, "released": false, "summary": "内容将在播出后解锁。" },
|
||||||
|
{ "id": 11, "title": "尚未命名", "duration": "--:--", "date": "待更新", "progress": 0, "released": false, "summary": "内容将在播出后解锁。" },
|
||||||
|
{ "id": 12, "title": "尚未命名", "duration": "--:--", "date": "待更新", "progress": 0, "released": false, "summary": "内容将在播出后解锁。" }
|
||||||
|
],
|
||||||
|
"products": [
|
||||||
|
{ "id": "SE-FIG-01", "name": "澪·观星时刻 1/7 手办", "category": "收藏手办", "price": 699, "originalPrice": 759, "badge": "预售", "stock": "剩余 86 件", "imagePosition": "left", "rating": 4.9 },
|
||||||
|
{ "id": "SE-BOOK-01", "name": "星海回响 设定集礼盒", "category": "画集 / 亚克力", "price": 189, "originalPrice": 219, "badge": "新品", "stock": "现货", "imagePosition": "center", "rating": 4.8 },
|
||||||
|
{ "id": "SE-JKT-01", "name": "天文观测部刺绣夹克", "category": "服饰", "price": 329, "originalPrice": 369, "badge": "限定", "stock": "部分尺码紧张", "imagePosition": "right", "rating": 4.7 }
|
||||||
|
],
|
||||||
|
"ratingDistribution": [
|
||||||
|
{ "stars": 5, "percent": 78 },
|
||||||
|
{ "stars": 4, "percent": 16 },
|
||||||
|
{ "stars": 3, "percent": 4 },
|
||||||
|
{ "stars": 2, "percent": 1 },
|
||||||
|
{ "stars": 1, "percent": 1 }
|
||||||
|
],
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"author": "北纬三十度",
|
||||||
|
"initial": "北",
|
||||||
|
"tone": "violet",
|
||||||
|
"score": 5,
|
||||||
|
"time": "2 小时前",
|
||||||
|
"episode": "看到第 8 话",
|
||||||
|
"content": "第七话停电后那十一秒的银河太震撼了。没有刻意煽情,角色只是安静地站在那里,反而把前面所有铺垫都接住了。美术和配乐都值得反复看。",
|
||||||
|
"likes": 328,
|
||||||
|
"spoiler": false,
|
||||||
|
"replies": [
|
||||||
|
{ "id": 1001, "author": "玻璃汽水", "initial": "汽", "content": "同感,环境音慢慢消失的处理尤其好。", "time": "1 小时前", "likes": 24 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 102,
|
||||||
|
"author": "纸飞机研究社",
|
||||||
|
"initial": "纸",
|
||||||
|
"tone": "blue",
|
||||||
|
"score": 4,
|
||||||
|
"time": "昨天 22:14",
|
||||||
|
"episode": "看到第 8 话",
|
||||||
|
"content": "设定很浪漫,主线推进也稳。希望后四集能多解释一点穹顶系统和遥的过去,现在伏笔已经很多了。",
|
||||||
|
"likes": 146,
|
||||||
|
"spoiler": false,
|
||||||
|
"replies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 103,
|
||||||
|
"author": "折射率",
|
||||||
|
"initial": "折",
|
||||||
|
"tone": "coral",
|
||||||
|
"score": 5,
|
||||||
|
"time": "昨天 18:40",
|
||||||
|
"episode": "看到第 7 话",
|
||||||
|
"content": "镜头语言非常克制,观测站、潮汐和广播这些意象形成了很完整的呼应。第七话结尾已经加入我的年度画面候选。",
|
||||||
|
"likes": 97,
|
||||||
|
"spoiler": false,
|
||||||
|
"replies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 104,
|
||||||
|
"author": "午后睡不醒",
|
||||||
|
"initial": "午",
|
||||||
|
"tone": "green",
|
||||||
|
"score": 4,
|
||||||
|
"time": "08-20",
|
||||||
|
"episode": "看到第 6 话",
|
||||||
|
"content": "人物相处很自然,小机器人每次出现都很可爱。节奏偏慢,但正好适合每周追一集慢慢消化。",
|
||||||
|
"likes": 61,
|
||||||
|
"spoiler": false,
|
||||||
|
"replies": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
73
app/data/content-templates.json
Normal file
73
app/data/content-templates.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"listItems": [
|
||||||
|
{ "id": "DOC-2048", "title": "世界观设定总览", "description": "新港市、穹顶系统与海上风场的核心设定汇总", "category": "世界观", "status": "已发布", "tags": ["核心设定", "公开"], "owner": "林知夏", "initial": "林", "updated": "10 分钟前", "favorite": true },
|
||||||
|
{ "id": "DOC-2047", "title": "第 09 话分镜评审记录", "description": "彗星抵达之夜 · 第二轮分镜意见与修改清单", "category": "制作资料", "status": "评审中", "tags": ["分镜", "S1E09"], "owner": "周砚", "initial": "周", "updated": "今天 14:32", "favorite": true },
|
||||||
|
{ "id": "DOC-2046", "title": "角色语言风格指南", "description": "主要角色语气、习惯用词与情绪表达边界", "category": "角色", "status": "已发布", "tags": ["角色设定", "编剧"], "owner": "唐可", "initial": "唐", "updated": "今天 11:08", "favorite": false },
|
||||||
|
{ "id": "DOC-2045", "title": "观测站场景资产清单", "description": "旧港观测站内外景模型、道具及材质完成度", "category": "制作资料", "status": "草稿", "tags": ["场景", "资产"], "owner": "许原", "initial": "许", "updated": "昨天 18:20", "favorite": false },
|
||||||
|
{ "id": "DOC-2044", "title": "澪 · 角色完整档案", "description": "角色经历、人物关系、服装变化与关键转折", "category": "角色", "status": "已发布", "tags": ["主角", "重要"], "owner": "林知夏", "initial": "林", "updated": "昨天 16:45", "favorite": true },
|
||||||
|
{ "id": "DOC-2043", "title": "穹顶维护局组织架构", "description": "维护局各部门职责、权限层级及主要人物", "category": "世界观", "status": "已发布", "tags": ["组织", "关系图"], "owner": "陈默", "initial": "陈", "updated": "08-20", "favorite": false },
|
||||||
|
{ "id": "DOC-2042", "title": "第一季音乐主题说明", "description": "角色主题、环境动机与配器方向备忘", "category": "音乐", "status": "评审中", "tags": ["原声", "主题动机"], "owner": "白川", "initial": "白", "updated": "08-20", "favorite": false },
|
||||||
|
{ "id": "DOC-2041", "title": "官方周边视觉规范", "description": "商品标识、色彩、图案及包装应用规则", "category": "品牌", "status": "已发布", "tags": ["周边", "VI"], "owner": "顾言", "initial": "顾", "updated": "08-19", "favorite": false },
|
||||||
|
{ "id": "DOC-2040", "title": "第 08 话配音修改单", "description": "最终混音前的台词重录与气口调整说明", "category": "制作资料", "status": "已归档", "tags": ["配音", "S1E08"], "owner": "周砚", "initial": "周", "updated": "08-18", "favorite": false },
|
||||||
|
{ "id": "DOC-2039", "title": "社区剧透管理规则", "description": "评论区剧透标记、折叠与举报处理规范", "category": "运营", "status": "已发布", "tags": ["社区", "规则"], "owner": "叶青", "initial": "叶", "updated": "08-17", "favorite": false }
|
||||||
|
],
|
||||||
|
"character": {
|
||||||
|
"name": "天野澪",
|
||||||
|
"nameEn": "MIO AMANO",
|
||||||
|
"code": "CHARACTER / SE-001",
|
||||||
|
"role": "天文部部长 · 第一主角",
|
||||||
|
"age": 17,
|
||||||
|
"birthday": "9 月 17 日",
|
||||||
|
"height": "161 cm",
|
||||||
|
"voice": "水濑遥",
|
||||||
|
"affiliation": "新港第一高校 · 天文观测部",
|
||||||
|
"quote": "就算看不见,星星也没有消失。",
|
||||||
|
"bio": "生活在被人造穹顶遮蔽的新港市。外表安静克制,对未知事物却有近乎固执的好奇心。十二年前的一场停电让她第一次看见真正的银河,也促使她加入濒临废部的天文观测部。故事开始时,她在旧观测站接收到一段来源不明的星际讯号。",
|
||||||
|
"tags": ["第一主角", "天文部", "观测者", "理性型", "隐藏往事", "星之继承者"],
|
||||||
|
"traits": [
|
||||||
|
{ "label": "观察力", "value": 92 },
|
||||||
|
{ "label": "行动力", "value": 68 },
|
||||||
|
{ "label": "共情力", "value": 81 },
|
||||||
|
{ "label": "表达力", "value": 55 }
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
{ "name": "星图记忆", "description": "能够快速辨认星图与观测坐标", "level": "S" },
|
||||||
|
{ "name": "信号解码", "description": "熟悉旧式短波设备与基础编码", "level": "A" },
|
||||||
|
{ "name": "夜间摄影", "description": "擅长长曝光与天体影像处理", "level": "A" }
|
||||||
|
],
|
||||||
|
"relations": [
|
||||||
|
{ "name": "朝仓遥", "initial": "遥", "relation": "搭档 / 转学生", "tone": "blue" },
|
||||||
|
{ "name": "佐仓奈奈", "initial": "奈", "relation": "好友 / 副部长", "tone": "coral" },
|
||||||
|
{ "name": "天野诚一", "initial": "诚", "relation": "父亲 / 失踪", "tone": "gray" }
|
||||||
|
],
|
||||||
|
"timeline": [
|
||||||
|
{ "episode": "EP.01", "title": "接收到十二年前的讯号", "type": "初登场" },
|
||||||
|
{ "episode": "EP.03", "title": "在暴雨中启动地面信标", "type": "关键行动" },
|
||||||
|
{ "episode": "EP.07", "title": "第一次向遥说起父亲", "type": "关系转折" },
|
||||||
|
{ "episode": "EP.08", "title": "决定公开穹顶观测数据", "type": "人物成长" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"id": "md-001",
|
||||||
|
"title": "第 09 话创作说明",
|
||||||
|
"category": "制作笔记",
|
||||||
|
"updated": "刚刚",
|
||||||
|
"content": "# 第 09 话:彗星抵达之夜\n\n> 本集的核心不是彗星,而是角色终于决定一起面对真相。\n\n## 本集目标\n\n- 完成澪与遥的关系转折\n- 揭示穹顶开放窗口的真实用途\n- 回收第 01 话的短波讯号伏笔\n\n## 情绪曲线\n\n从**平静等待**逐步走向*不可逆的选择*。前半段保持低饱和与长镜头,彗星出现后切换为高对比画面。\n\n### 关键场景\n\n1. 天台上的最后一次设备校准\n2. 维护局封锁旧港观测站\n3. 全城第一次主动关闭穹顶照明\n\n---\n\n## 制作提醒\n\n请在最终混音中保留 11 秒完全无配乐的城市环境音。\n\n`signal.open(20:00)` 是本集反复出现的视觉符号。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "md-002",
|
||||||
|
"title": "角色台词风格指南",
|
||||||
|
"category": "角色设定",
|
||||||
|
"updated": "昨天 16:45",
|
||||||
|
"content": "# 角色台词风格指南\n\n## 天野澪\n\n语言简洁,优先陈述观察结果,很少直接表达情绪。\n\n- 避免连续使用感叹句\n- 紧张时会重复确认时间\n- 用「也许」代替没有依据的判断\n\n> 角色不是冷漠,只是不擅长把感受翻译成语言。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "md-003",
|
||||||
|
"title": "世界观名词表",
|
||||||
|
"category": "世界观",
|
||||||
|
"updated": "08-20",
|
||||||
|
"content": "# 世界观名词表\n\n## 人造穹顶\n\n覆盖新港市上空的气候调节设施,正式编号为 `NC-DOME-03`。\n\n## 地面信标\n\n旧时代天文观测网络的定位装置,目前仅发现三座。\n\n## 潮汐电台\n\n每晚零点在短波频段出现的无人广播。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
39
app/data/image-host.json
Normal file
39
app/data/image-host.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"categories": ["全部", "动漫", "星空", "角色", "周边", "壁纸"],
|
||||||
|
"images": [
|
||||||
|
{ "id": "img-01", "title": "星海屋顶的约定", "src": "/anime/star-echoes-hero.png", "category": "壁纸", "ratio": "16 / 10", "position": "center", "width": 2048, "height": 1152, "size": "2.08 MB", "format": "PNG", "uploadedAt": "2026-08-10", "views": 1284, "tags": ["星空", "双人", "城市"] },
|
||||||
|
{ "id": "img-02", "title": "彗星划过观测台", "src": "/anime/star-echoes-hero.png", "category": "星空", "ratio": "4 / 5", "position": "24% center", "width": 2048, "height": 1152, "size": "2.08 MB", "format": "PNG", "uploadedAt": "2026-08-09", "views": 986, "tags": ["彗星", "银河", "观测台"] },
|
||||||
|
{ "id": "img-03", "title": "天野澪角色立绘", "src": "/anime/star-echoes-merch.png", "category": "角色", "ratio": "3 / 4", "position": "12% center", "width": 2048, "height": 1024, "size": "2.07 MB", "format": "PNG", "uploadedAt": "2026-08-08", "views": 2150, "tags": ["角色", "蓝发", "立绘"] },
|
||||||
|
{ "id": "img-04", "title": "星轨设定集封面", "src": "/anime/star-echoes-merch.png", "category": "周边", "ratio": "1 / 1", "position": "50% center", "width": 2048, "height": 1024, "size": "2.07 MB", "format": "PNG", "uploadedAt": "2026-08-08", "views": 733, "tags": ["设定集", "封面", "周边"] },
|
||||||
|
{ "id": "img-05", "title": "深蓝飞行夹克", "src": "/anime/star-echoes-merch.png", "category": "周边", "ratio": "4 / 5", "position": "88% center", "width": 2048, "height": 1024, "size": "2.07 MB", "format": "PNG", "uploadedAt": "2026-08-07", "views": 621, "tags": ["服饰", "夹克", "星轨"] },
|
||||||
|
{ "id": "img-06", "title": "城市日落与星河", "src": "/anime/star-echoes-hero.png", "category": "壁纸", "ratio": "3 / 2", "position": "center 76%", "width": 2048, "height": 1152, "size": "2.08 MB", "format": "PNG", "uploadedAt": "2026-08-06", "views": 1672, "tags": ["日落", "城市", "宽屏"] },
|
||||||
|
{ "id": "img-07", "title": "观测台机器人", "src": "/anime/star-echoes-hero.png", "category": "动漫", "ratio": "3 / 4", "position": "94% center", "width": 2048, "height": 1152, "size": "2.08 MB", "format": "PNG", "uploadedAt": "2026-08-05", "views": 804, "tags": ["机器人", "观测台", "夜景"] },
|
||||||
|
{ "id": "img-08", "title": "月光下的两人", "src": "/anime/star-echoes-hero.png", "category": "动漫", "ratio": "4 / 5", "position": "68% center", "width": 2048, "height": 1152, "size": "2.08 MB", "format": "PNG", "uploadedAt": "2026-08-04", "views": 1493, "tags": ["双人", "月光", "剧情"] },
|
||||||
|
{ "id": "img-09", "title": "星海亚克力立牌", "src": "/anime/star-echoes-merch.png", "category": "周边", "ratio": "2 / 3", "position": "62% center", "width": 2048, "height": 1024, "size": "2.07 MB", "format": "PNG", "uploadedAt": "2026-08-03", "views": 445, "tags": ["立牌", "角色", "收藏"] },
|
||||||
|
{ "id": "img-10", "title": "银河与星座连线", "src": "/anime/star-echoes-hero.png", "category": "星空", "ratio": "5 / 4", "position": "40% 18%", "width": 2048, "height": 1152, "size": "2.08 MB", "format": "PNG", "uploadedAt": "2026-08-02", "views": 1118, "tags": ["银河", "星座", "蓝色"] },
|
||||||
|
{ "id": "img-11", "title": "观测站远景", "src": "/anime/star-echoes-hero.png", "category": "壁纸", "ratio": "1 / 1", "position": "90% center", "width": 2048, "height": 1152, "size": "2.08 MB", "format": "PNG", "uploadedAt": "2026-08-01", "views": 758, "tags": ["建筑", "远景", "星空"] },
|
||||||
|
{ "id": "img-12", "title": "星轨纪念明信片", "src": "/anime/star-echoes-merch.png", "category": "周边", "ratio": "16 / 11", "position": "54% 82%", "width": 2048, "height": 1024, "size": "2.07 MB", "format": "PNG", "uploadedAt": "2026-07-30", "views": 390, "tags": ["明信片", "纪念品", "插画"] }
|
||||||
|
],
|
||||||
|
"albums": [
|
||||||
|
{ "id": "album-01", "title": "《星海回响》官方壁纸", "description": "动画第一季公开的横屏、竖屏与角色壁纸合集。", "category": "壁纸", "coverId": "img-01", "imageIds": ["img-01", "img-02", "img-06", "img-08", "img-10", "img-11"], "updatedAt": "2026-08-10", "tags": ["官方", "壁纸", "第一季"] },
|
||||||
|
{ "id": "album-02", "title": "角色与场景设定", "description": "主要角色、天文台和城市概念设计整理。", "category": "动漫", "coverId": "img-03", "imageIds": ["img-03", "img-07", "img-08", "img-10", "img-11"], "updatedAt": "2026-08-08", "tags": ["设定", "角色", "场景"] },
|
||||||
|
{ "id": "album-03", "title": "星轨周边产品图", "description": "设定集、服饰、立牌与纪念明信片展示图。", "category": "周边", "coverId": "img-05", "imageIds": ["img-04", "img-05", "img-09", "img-12"], "updatedAt": "2026-08-07", "tags": ["周边", "产品", "收藏"] }
|
||||||
|
],
|
||||||
|
"feed": [
|
||||||
|
{ "kind": "album", "id": "album-01" },
|
||||||
|
{ "kind": "image", "id": "img-03" },
|
||||||
|
{ "kind": "image", "id": "img-10" },
|
||||||
|
{ "kind": "image", "id": "img-02" },
|
||||||
|
{ "kind": "album", "id": "album-02" },
|
||||||
|
{ "kind": "image", "id": "img-05" },
|
||||||
|
{ "kind": "image", "id": "img-06" },
|
||||||
|
{ "kind": "image", "id": "img-07" },
|
||||||
|
{ "kind": "album", "id": "album-03" },
|
||||||
|
{ "kind": "image", "id": "img-08" },
|
||||||
|
{ "kind": "image", "id": "img-04" },
|
||||||
|
{ "kind": "image", "id": "img-11" },
|
||||||
|
{ "kind": "image", "id": "img-09" },
|
||||||
|
{ "kind": "image", "id": "img-01" },
|
||||||
|
{ "kind": "image", "id": "img-12" }
|
||||||
|
]
|
||||||
|
}
|
||||||
15
app/data/maps/aus-adm1.geojson
Normal file
15
app/data/maps/aus-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
33
app/data/maps/bra-adm1.geojson
Normal file
33
app/data/maps/bra-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
15
app/data/maps/can-adm1.geojson
Normal file
15
app/data/maps/can-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
40
app/data/maps/chn-adm1.geojson
Normal file
40
app/data/maps/chn-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
22
app/data/maps/deu-adm1.geojson
Normal file
22
app/data/maps/deu-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
42
app/data/maps/ind-adm1.geojson
Normal file
42
app/data/maps/ind-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
53
app/data/maps/jpn-adm1.geojson
Normal file
53
app/data/maps/jpn-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
23
app/data/maps/kor-adm1.geojson
Normal file
23
app/data/maps/kor-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
17
app/data/maps/prk-adm1.geojson
Normal file
17
app/data/maps/prk-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
89
app/data/maps/rus-adm1.geojson
Normal file
89
app/data/maps/rus-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
19
app/data/maps/sau-adm1.geojson
Normal file
19
app/data/maps/sau-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
83
app/data/maps/tha-adm1.geojson
Normal file
83
app/data/maps/tha-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
62
app/data/maps/usa-adm1.geojson
Normal file
62
app/data/maps/usa-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
15
app/data/maps/zaf-adm1.geojson
Normal file
15
app/data/maps/zaf-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
112
app/data/media-player.json
Normal file
112
app/data/media-player.json
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"videos": [
|
||||||
|
{
|
||||||
|
"id": "video-flower",
|
||||||
|
"title": "晨光中的花",
|
||||||
|
"subtitle": "HTML5 视频播放器演示片段",
|
||||||
|
"src": "https://developer.mozilla.org/shared-assets/videos/flower.mp4",
|
||||||
|
"poster": "/anime/star-echoes-hero.png",
|
||||||
|
"durationLabel": "00:30",
|
||||||
|
"quality": "1080P",
|
||||||
|
"views": "1.2 万次播放"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "video-morning",
|
||||||
|
"title": "清晨微距练习",
|
||||||
|
"subtitle": "自然光与微距镜头示例",
|
||||||
|
"src": "https://developer.mozilla.org/shared-assets/videos/flower.mp4#t=2",
|
||||||
|
"poster": "/anime/star-echoes-merch.png",
|
||||||
|
"durationLabel": "00:28",
|
||||||
|
"quality": "720P",
|
||||||
|
"views": "8,620 次播放"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "video-color",
|
||||||
|
"title": "夏日色彩片段",
|
||||||
|
"subtitle": "短视频色彩与节奏演示",
|
||||||
|
"src": "https://developer.mozilla.org/shared-assets/videos/flower.mp4#t=5",
|
||||||
|
"poster": "/anime/star-echoes-hero.png",
|
||||||
|
"durationLabel": "00:25",
|
||||||
|
"quality": "1080P",
|
||||||
|
"views": "6,438 次播放"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "track-01",
|
||||||
|
"title": "Starlight Drift",
|
||||||
|
"artist": "SoundHelix · Ambient Session",
|
||||||
|
"album": "Orbit Night Radio",
|
||||||
|
"src": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
|
||||||
|
"cover": "/anime/star-echoes-merch.png",
|
||||||
|
"position": "12% center",
|
||||||
|
"durationLabel": "06:13",
|
||||||
|
"lyrics": [
|
||||||
|
{ "time": 0, "text": "星光落在安静的窗边", "translation": "Starlight rests beside the quiet window" },
|
||||||
|
{ "time": 14, "text": "城市还没有醒来", "translation": "The city has not awakened yet" },
|
||||||
|
{ "time": 30, "text": "我沿着昨夜留下的轨迹", "translation": "I follow the trail left by last night" },
|
||||||
|
{ "time": 47, "text": "寻找你说过的那片海", "translation": "Searching for the sea you once described" },
|
||||||
|
{ "time": 65, "text": "风把遥远的回声带来", "translation": "The wind carries a distant echo" },
|
||||||
|
{ "time": 84, "text": "像一颗缓慢靠近的星", "translation": "Like a star slowly drawing near" },
|
||||||
|
{ "time": 104, "text": "如果时间此刻停下来", "translation": "If time could stop right here" },
|
||||||
|
{ "time": 126, "text": "就让我们漂流在光里", "translation": "Let us drift inside the light" },
|
||||||
|
{ "time": 149, "text": "越过沉睡的天文台", "translation": "Beyond the sleeping observatory" },
|
||||||
|
{ "time": 173, "text": "穿过没有名字的云海", "translation": "Through the nameless sea of clouds" },
|
||||||
|
{ "time": 198, "text": "你在银河尽头等待", "translation": "You wait at the end of the galaxy" },
|
||||||
|
{ "time": 224, "text": "而我循着微光回来", "translation": "And I return following the faint glow" },
|
||||||
|
{ "time": 252, "text": "星光落在安静的窗边", "translation": "Starlight rests beside the quiet window" },
|
||||||
|
{ "time": 282, "text": "我们的故事仍在盛开", "translation": "Our story is still in bloom" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "track-02",
|
||||||
|
"title": "Moon Observatory",
|
||||||
|
"artist": "SoundHelix · Electronic Session",
|
||||||
|
"album": "Orbit Night Radio",
|
||||||
|
"src": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3",
|
||||||
|
"cover": "/anime/star-echoes-merch.png",
|
||||||
|
"position": "50% center",
|
||||||
|
"durationLabel": "07:05",
|
||||||
|
"lyrics": [
|
||||||
|
{ "time": 0, "text": "月光绕过旧观测站", "translation": "Moonlight circles the old observatory" },
|
||||||
|
{ "time": 16, "text": "银色屋顶写满答案", "translation": "Silver rooftops are filled with answers" },
|
||||||
|
{ "time": 34, "text": "有人把愿望藏进星盘", "translation": "Someone hid a wish inside the star chart" },
|
||||||
|
{ "time": 53, "text": "等待下一次夜晚", "translation": "Waiting for the next night" },
|
||||||
|
{ "time": 73, "text": "让所有钟声变得缓慢", "translation": "Let every bell begin to slow" },
|
||||||
|
{ "time": 94, "text": "让云层停在山峦", "translation": "Let the clouds rest over the mountains" },
|
||||||
|
{ "time": 116, "text": "你说宇宙并不孤单", "translation": "You said the universe is not alone" },
|
||||||
|
{ "time": 139, "text": "每颗星都有同伴", "translation": "Every star has a companion" },
|
||||||
|
{ "time": 164, "text": "我们在月光里交换晚安", "translation": "We exchange goodnight in the moonlight" },
|
||||||
|
{ "time": 190, "text": "把明天交给夜航的帆", "translation": "Leave tomorrow to the sails of night" },
|
||||||
|
{ "time": 218, "text": "当天空再次变蓝", "translation": "When the sky turns blue again" },
|
||||||
|
{ "time": 248, "text": "我会记得这一晚", "translation": "I will remember this night" },
|
||||||
|
{ "time": 280, "text": "月光绕过旧观测站", "translation": "Moonlight circles the old observatory" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "track-03",
|
||||||
|
"title": "Afterglow City",
|
||||||
|
"artist": "SoundHelix · Dream Session",
|
||||||
|
"album": "Orbit Night Radio",
|
||||||
|
"src": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3",
|
||||||
|
"cover": "/anime/star-echoes-hero.png",
|
||||||
|
"position": "center",
|
||||||
|
"durationLabel": "05:48",
|
||||||
|
"lyrics": [
|
||||||
|
{ "time": 0, "text": "日落把街道染成金色", "translation": "Sunset paints the streets in gold" },
|
||||||
|
{ "time": 15, "text": "晚风翻过城市的歌", "translation": "The evening wind turns the city's song" },
|
||||||
|
{ "time": 32, "text": "我们站在高楼的边缘", "translation": "We stand at the edge of the rooftop" },
|
||||||
|
{ "time": 50, "text": "看最后一束光经过", "translation": "Watching the final ray pass by" },
|
||||||
|
{ "time": 69, "text": "别急着说再见", "translation": "Do not hurry to say goodbye" },
|
||||||
|
{ "time": 89, "text": "夜色才刚刚出现", "translation": "The night has only just appeared" },
|
||||||
|
{ "time": 110, "text": "霓虹会替我们点亮", "translation": "Neon lights will shine for us" },
|
||||||
|
{ "time": 132, "text": "回家的那条地平线", "translation": "The horizon that leads us home" },
|
||||||
|
{ "time": 156, "text": "当余晖留在你的侧脸", "translation": "When afterglow rests upon your face" },
|
||||||
|
{ "time": 181, "text": "所有喧闹都听不见", "translation": "All the noise begins to disappear" },
|
||||||
|
{ "time": 208, "text": "只剩心跳一遍一遍", "translation": "Only heartbeats remain, again and again" },
|
||||||
|
{ "time": 238, "text": "陪我们走向明天", "translation": "Walking with us into tomorrow" },
|
||||||
|
{ "time": 270, "text": "日落之后星光重现", "translation": "After sunset, starlight returns" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
147
app/data/produce-map.json
Normal file
147
app/data/produce-map.json
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{
|
||||||
|
"tabs": [
|
||||||
|
{
|
||||||
|
"id": "china", "label": "中国", "country": "中国", "iso": "CHN", "map": "/maps/chn-adm1.geojson", "accent": "#d66b45",
|
||||||
|
"description": "从东北粮仓到西南高原,展示中国主要特色农产区。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Shandong Province", "localName": "山东省", "crops": ["苹果", "花生", "大蒜", "冬小麦"], "season": "5月—10月", "summary": "温带季风气候与广阔平原形成果品、油料和粮食复合产区。" },
|
||||||
|
{ "name": "Hebei Province", "localName": "河北省", "crops": ["小麦", "玉米", "梨", "板栗"], "season": "6月—10月", "summary": "点击可继续下钻到河北省 11 个地级市,查看市级行政区及代表产物。" },
|
||||||
|
{ "name": "Shanghai Municipality", "localName": "上海市", "crops": ["崇明大米", "水蜜桃", "翠冠梨", "青浦茭白"], "season": "5月—10月", "summary": "长江入海口的冲积平原和都市近郊农业,形成稻米、水果及特色水生蔬菜产区。" },
|
||||||
|
{ "name": "Chongqing Municipality", "localName": "重庆市", "crops": ["柑橘", "榨菜", "花椒", "茶叶"], "season": "7月—次年3月", "summary": "山地立体气候与长江河谷共同形成柑橘、花椒、榨菜和茶叶特色产区。" },
|
||||||
|
{ "name": "Yunnan Province", "localName": "云南省", "crops": ["咖啡", "鲜花", "普洱茶", "蓝莓"], "season": "全年分批", "summary": "立体气候明显,不同海拔形成多样化热带与高原特色作物。" },
|
||||||
|
{ "name": "Heilongjiang Province", "localName": "黑龙江省", "crops": ["大豆", "玉米", "水稻", "马铃薯"], "season": "9月—10月", "summary": "黑土地集中连片,是重要的大豆、玉米与优质粳稻产地。" },
|
||||||
|
{ "name": "Xinjiang Uyghur Autonomous Region", "localName": "新疆维吾尔自治区", "crops": ["棉花", "葡萄", "哈密瓜", "番茄"], "season": "7月—10月", "summary": "日照时间长、昼夜温差大,适合棉花和高糖度瓜果生长。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "japan", "label": "日本", "country": "日本", "iso": "JPN", "map": "/maps/jpn-adm1.geojson", "accent": "#cf5f72",
|
||||||
|
"description": "按都道府县查看日本稻米、茶叶、果品与南方作物。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Hokkaido", "localName": "北海道", "crops": ["马铃薯", "甜玉米", "甜菜", "乳制品"], "season": "7月—10月", "summary": "冷凉气候与大规模农场形成薯类、旱作和乳畜产品优势。" },
|
||||||
|
{ "name": "Aomori", "localName": "青森县", "crops": ["苹果", "大蒜", "牛蒡", "樱桃"], "season": "8月—11月", "summary": "日本代表性苹果产区,凉爽气候有利于果实着色和糖分积累。" },
|
||||||
|
{ "name": "Shizuoka", "localName": "静冈县", "crops": ["绿茶", "柑橘", "山葵", "草莓"], "season": "4月—12月", "summary": "丘陵茶园与温暖沿海共同塑造茶叶和园艺作物产区。" },
|
||||||
|
{ "name": "Kagoshima Prefecture", "localName": "鹿儿岛县", "crops": ["甘薯", "黑猪", "茶叶", "甘蔗"], "season": "全年分批", "summary": "温暖湿润,火山土壤适合甘薯、茶叶和亚热带作物。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "southeast-asia", "label": "东南亚", "country": "泰国", "iso": "THA", "map": "/maps/tha-adm1.geojson", "accent": "#3c9788",
|
||||||
|
"description": "以泰国为代表地图,展示东南亚稻米、热带水果与高地作物产区。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Chiang Mai Province", "localName": "清迈府", "crops": ["龙眼", "咖啡", "草莓", "高山蔬菜"], "season": "11月—3月", "summary": "北部高地昼夜温差明显,形成咖啡、龙眼和冷凉蔬菜产区。" },
|
||||||
|
{ "name": "Chanthaburi Province", "localName": "尖竹汶府", "crops": ["榴莲", "山竹", "红毛丹", "胡椒"], "season": "4月—7月", "summary": "泰国东部热带水果核心产区,以榴莲和山竹集中上市著名。" },
|
||||||
|
{ "name": "Nakhon Ratchasima Province", "localName": "呵叻府", "crops": ["木薯", "甘蔗", "水稻", "玉米"], "season": "11月—4月", "summary": "东北部高原农业区,大田经济作物和香米生产规模较大。" },
|
||||||
|
{ "name": "Surat Thani Province", "localName": "素叻他尼府", "crops": ["椰子", "橡胶", "油棕", "红毛丹"], "season": "全年分批", "summary": "南部湿热气候适合椰子、橡胶、油棕和热带水果。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "india", "label": "印度地区", "country": "印度", "iso": "IND", "map": "/maps/ind-adm1.geojson", "accent": "#d2863c",
|
||||||
|
"description": "按邦和中央直辖区查看印度香料、茶叶、棉花与稻米产区。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Punjab", "localName": "旁遮普邦", "crops": ["小麦", "水稻", "棉花", "甘蔗"], "season": "4月及10月—11月", "summary": "灌溉体系完善,是印度重要的粮食和棉花生产区。" },
|
||||||
|
{ "name": "Mahārāshtra", "localName": "马哈拉施特拉邦", "crops": ["棉花", "甘蔗", "葡萄", "芒果"], "season": "1月—5月", "summary": "黑棉土和灌溉区共同支持棉花、甘蔗与园艺作物。" },
|
||||||
|
{ "name": "Kerala", "localName": "喀拉拉邦", "crops": ["椰子", "黑胡椒", "橡胶", "豆蔻"], "season": "全年分批", "summary": "西高止山和热带海岸形成印度代表性香料与种植园农业区。" },
|
||||||
|
{ "name": "Assam", "localName": "阿萨姆邦", "crops": ["红茶", "水稻", "黄麻", "柑橘"], "season": "3月—11月", "summary": "布拉马普特拉河谷湿润温暖,是世界重要红茶产区之一。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "south-korea", "label": "韩国", "country": "韩国", "iso": "KOR", "map": "/maps/kor-adm1.geojson", "accent": "#5278ba",
|
||||||
|
"description": "按特别市、广域市和道查看韩国稻米、果品与岛屿特色作物。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Jeju", "localName": "济州特别自治道", "crops": ["柑橘", "绿茶", "萝卜", "荞麦"], "season": "10月—3月", "summary": "火山土壤和海洋性气候形成韩国代表性柑橘与茶叶产区。" },
|
||||||
|
{ "name": "Gyeonggi", "localName": "京畿道", "crops": ["大米", "葡萄", "梨", "设施蔬菜"], "season": "8月—11月", "summary": "首都圈近郊农业发达,优质稻米、果园和设施园艺集中。" },
|
||||||
|
{ "name": "North Gyeongsang", "localName": "庆尚北道", "crops": ["苹果", "葡萄", "桃", "辣椒"], "season": "7月—11月", "summary": "内陆盆地日照充足,是韩国苹果和葡萄的重要产区。" },
|
||||||
|
{ "name": "South Jeolla", "localName": "全罗南道", "crops": ["水稻", "洋葱", "大蒜", "海藻"], "season": "5月—10月", "summary": "温暖西南沿海兼具粮食、蔬菜与海洋养殖产品。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "north-korea", "label": "朝鲜地区", "country": "朝鲜", "iso": "PRK", "map": "/maps/prk-adm1.geojson", "accent": "#a65757",
|
||||||
|
"description": "按直辖市和道查看朝鲜西部平原、山区与北部高原作物。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "South Hwanghae", "localName": "黄海南道", "crops": ["水稻", "玉米", "大豆", "小麦"], "season": "8月—10月", "summary": "西部平原较为开阔,是重要的粮食生产区域。" },
|
||||||
|
{ "name": "South Pyongan", "localName": "平安南道", "crops": ["水稻", "玉米", "苹果", "蔬菜"], "season": "8月—10月", "summary": "河谷和平原农业并存,粮食与城市周边蔬菜生产突出。" },
|
||||||
|
{ "name": "Ryanggang", "localName": "两江道", "crops": ["马铃薯", "大麦", "豆类", "蓝莓"], "season": "7月—9月", "summary": "北部高原气候冷凉,适合薯类、杂粮和耐寒浆果。" },
|
||||||
|
{ "name": "Kangwon", "localName": "江原道", "crops": ["玉米", "马铃薯", "大豆", "苹果"], "season": "8月—10月", "summary": "山地较多,以旱作粮食、薯类和耐寒果树为主。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "middle-east", "label": "中东地区", "country": "沙特阿拉伯", "iso": "SAU", "map": "/maps/sau-adm1.geojson", "accent": "#b58a3f",
|
||||||
|
"description": "以沙特阿拉伯为示例,展示绿洲农业与沿海渔农产品。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Eastern Region", "localName": "东部省", "crops": ["椰枣", "番茄", "黄瓜", "水产"], "season": "10月—5月", "summary": "绿洲与海湾沿岸农业并存,椰枣、设施蔬菜和水产突出。" },
|
||||||
|
{ "name": "Riyadh Region", "localName": "利雅得省", "crops": ["小麦", "椰枣", "苜蓿", "甜瓜"], "season": "冬春季", "summary": "以灌溉农业为主,粮食、饲草和果园集中在可利用水源周边。" },
|
||||||
|
{ "name": "Al Madinah Region", "localName": "麦地那省", "crops": ["阿吉瓦椰枣", "柑橘", "葡萄", "香草"], "season": "7月—10月", "summary": "历史悠久的绿洲果园,以特色椰枣和耐旱果树闻名。" },
|
||||||
|
{ "name": "Al-Qassim Region", "localName": "卡西姆省", "crops": ["椰枣", "小麦", "葡萄", "蔬菜"], "season": "8月—10月", "summary": "大型椰枣市场和灌溉农场集中,是中部重要农业区。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "africa", "label": "非洲地区", "country": "南非", "iso": "ZAF", "map": "/maps/zaf-adm1.geojson", "accent": "#c47d35",
|
||||||
|
"description": "以南非为示例,展示葡萄酒、柑橘和热带水果产区。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Western Cape", "localName": "西开普省", "crops": ["酿酒葡萄", "苹果", "梨", "橄榄"], "season": "12月—4月", "summary": "地中海气候显著,是南非葡萄酒和温带水果核心产区。" },
|
||||||
|
{ "name": "Limpopo", "localName": "林波波省", "crops": ["芒果", "牛油果", "柑橘", "坚果"], "season": "10月—3月", "summary": "温暖亚热带气候形成芒果、牛油果和柑橘优势带。" },
|
||||||
|
{ "name": "KwaZulu-Natal", "localName": "夸祖鲁-纳塔尔省", "crops": ["甘蔗", "香蕉", "菠萝", "夏威夷果"], "season": "全年分批", "summary": "湿润沿海地带适合甘蔗与热带、亚热带经济作物。" },
|
||||||
|
{ "name": "Mpumalanga", "localName": "普马兰加省", "crops": ["柑橘", "香蕉", "荔枝", "甘蔗"], "season": "3月—9月", "summary": "低地温暖、日照充足,是出口柑橘和热带水果产区。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "usa", "label": "美国", "country": "美国", "iso": "USA", "map": "/maps/usa-adm1.geojson", "accent": "#4674b8",
|
||||||
|
"description": "按州查看美国果蔬、谷物和特色经济作物带。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "California", "localName": "加利福尼亚州", "crops": ["杏仁", "葡萄", "草莓", "牛油果"], "season": "全年分批", "summary": "中央谷地和沿海气候带形成高价值果蔬与坚果农业。" },
|
||||||
|
{ "name": "Iowa", "localName": "艾奥瓦州", "crops": ["玉米", "大豆", "猪肉", "燕麦"], "season": "9月—11月", "summary": "肥沃草原土壤,是美国玉米和大豆带的重要核心州。" },
|
||||||
|
{ "name": "Florida", "localName": "佛罗里达州", "crops": ["橙子", "葡萄柚", "甘蔗", "番茄"], "season": "10月—6月", "summary": "亚热带气候适合柑橘、甘蔗与冬春季蔬菜生产。" },
|
||||||
|
{ "name": "Washington", "localName": "华盛顿州", "crops": ["苹果", "樱桃", "啤酒花", "小麦"], "season": "6月—11月", "summary": "山地雨影和灌溉河谷构成世界级苹果、樱桃与啤酒花产区。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "australia", "label": "澳大利亚", "country": "澳大利亚", "iso": "AUS", "map": "/maps/aus-adm1.geojson", "accent": "#3c9989",
|
||||||
|
"description": "展示澳大利亚热带园艺、谷物和葡萄酒产区。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Queensland", "localName": "昆士兰州", "crops": ["甘蔗", "香蕉", "芒果", "菠萝"], "season": "全年分批", "summary": "热带和亚热带海岸带是甘蔗与热带水果主要产区。" },
|
||||||
|
{ "name": "Victoria", "localName": "维多利亚州", "crops": ["葡萄", "乳制品", "苹果", "小麦"], "season": "2月—5月", "summary": "温和气候兼具葡萄酒、乳业、果园和谷物生产。" },
|
||||||
|
{ "name": "Western Australia", "localName": "西澳大利亚州", "crops": ["小麦", "油菜", "葡萄", "牛油果"], "season": "10月—1月", "summary": "西南部地中海气候区集中生产谷物、油籽和葡萄酒。" },
|
||||||
|
{ "name": "Tasmania", "localName": "塔斯马尼亚州", "crops": ["樱桃", "苹果", "啤酒花", "马铃薯"], "season": "12月—4月", "summary": "冷凉海洋性气候适合高品质温带水果和种薯。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "russia", "label": "俄罗斯", "country": "俄罗斯", "iso": "RUS", "map": "/maps/rus-adm1.geojson", "accent": "#667eaa",
|
||||||
|
"description": "展示俄罗斯南部粮食带与内陆特色农产区。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Krasnodar Krai", "localName": "克拉斯诺达尔边疆区", "crops": ["小麦", "向日葵", "水稻", "葡萄"], "season": "6月—10月", "summary": "黑海沿岸和肥沃平原构成俄罗斯最重要的农业区域之一。" },
|
||||||
|
{ "name": "Stavropol Krai", "localName": "斯塔夫罗波尔边疆区", "crops": ["冬小麦", "玉米", "向日葵", "甜菜"], "season": "6月—9月", "summary": "草原地带日照充足,以冬小麦和油料作物为主。" },
|
||||||
|
{ "name": "Rostov Oblast", "localName": "罗斯托夫州", "crops": ["小麦", "大麦", "向日葵", "蔬菜"], "season": "7月—10月", "summary": "顿河流域是俄罗斯南部粮食与油料作物核心产区。" },
|
||||||
|
{ "name": "Altai Krai", "localName": "阿尔泰边疆区", "crops": ["荞麦", "小麦", "燕麦", "蜂蜜"], "season": "8月—10月", "summary": "西西伯利亚农牧交错区,以荞麦、谷物和蜂产品著名。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "europe", "label": "欧洲", "country": "德国", "iso": "DEU", "map": "/maps/deu-adm1.geojson", "accent": "#6d63b5",
|
||||||
|
"description": "以德国为示例,展示欧洲谷物、啤酒花与葡萄产区。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Bayern", "localName": "巴伐利亚州", "crops": ["啤酒花", "大麦", "甜菜", "乳制品"], "season": "7月—10月", "summary": "啤酒花、酿造大麦和乳畜业共同构成典型南德农业景观。" },
|
||||||
|
{ "name": "Niedersachsen", "localName": "下萨克森州", "crops": ["马铃薯", "甜菜", "芦笋", "苹果"], "season": "4月—10月", "summary": "北德平原农业发达,是马铃薯、甜菜和园艺作物重要产区。" },
|
||||||
|
{ "name": "Rheinland-Pfalz", "localName": "莱茵兰-普法尔茨州", "crops": ["葡萄", "樱桃", "苹果", "芦笋"], "season": "4月—10月", "summary": "莱茵河与摩泽尔河谷以优质酿酒葡萄和果园闻名。" },
|
||||||
|
{ "name": "Baden-Württemberg", "localName": "巴登-符腾堡州", "crops": ["葡萄", "苹果", "啤酒花", "玉米"], "season": "8月—10月", "summary": "山谷和湖区形成葡萄、苹果与特色啤酒花产区。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "canada", "label": "加拿大", "country": "加拿大", "iso": "CAN", "map": "/maps/can-adm1.geojson", "accent": "#c85855",
|
||||||
|
"description": "按省查看加拿大草原粮油、果园和枫糖产区。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Saskatchewan", "localName": "萨斯喀彻温省", "crops": ["油菜籽", "小麦", "扁豆", "豌豆"], "season": "8月—10月", "summary": "加拿大草原农业核心省份,粮食、油籽和豆类规模化生产突出。" },
|
||||||
|
{ "name": "Ontario", "localName": "安大略省", "crops": ["玉米", "大豆", "苹果", "葡萄"], "season": "8月—11月", "summary": "五大湖调节气候,粮油作物与尼亚加拉果园、葡萄园并存。" },
|
||||||
|
{ "name": "British Columbia", "localName": "不列颠哥伦比亚省", "crops": ["蓝莓", "樱桃", "葡萄", "苹果"], "season": "6月—10月", "summary": "沿海低地与奥肯那根谷地形成浆果、核果和葡萄酒产区。" },
|
||||||
|
{ "name": "Quebec", "localName": "魁北克省", "crops": ["枫糖", "蓝莓", "玉米", "蔓越莓"], "season": "3月及夏秋季", "summary": "春季枫糖与夏秋浆果、谷物共同构成鲜明季节农业。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "south-america", "label": "南美洲", "country": "巴西", "iso": "BRA", "map": "/maps/bra-adm1.geojson", "accent": "#3c9462",
|
||||||
|
"description": "以巴西为示例,展示咖啡、大豆、可可与南方温带作物。",
|
||||||
|
"regions": [
|
||||||
|
{ "name": "Sao Paulo", "localName": "圣保罗州", "crops": ["甘蔗", "橙子", "咖啡", "花生"], "season": "全年分批", "summary": "产业链完善,是甘蔗、橙汁与咖啡生产加工的重要中心。" },
|
||||||
|
{ "name": "Minas Gerais", "localName": "米纳斯吉拉斯州", "crops": ["咖啡", "乳制品", "大豆", "玉米"], "season": "5月—9月", "summary": "高地气候适合阿拉比卡咖啡,也是重要的乳畜与粮食产区。" },
|
||||||
|
{ "name": "Bahia", "localName": "巴伊亚州", "crops": ["可可", "芒果", "木薯", "椰子"], "season": "全年分批", "summary": "热带海岸与内陆灌溉区生产可可、热带水果和木薯。" },
|
||||||
|
{ "name": "Rio Grande do Sul", "localName": "南里奥格兰德州", "crops": ["大豆", "水稻", "葡萄", "烟草"], "season": "1月—5月", "summary": "南部温带农业区,以大豆、水稻和葡萄酒产业著名。" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
101
app/data/repeatable-forms.json
Normal file
101
app/data/repeatable-forms.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"templates": ["项目交付清单", "门店巡检记录", "采购物料明细"],
|
||||||
|
"forms": [
|
||||||
|
{
|
||||||
|
"id": "FORM-202608-018",
|
||||||
|
"title": "官网改版交付资料",
|
||||||
|
"code": "DELIVERY-2026-08",
|
||||||
|
"status": "编辑中",
|
||||||
|
"updatedAt": "今天 16:42",
|
||||||
|
"createdBy": "林知夏",
|
||||||
|
"tags": ["官网改版", "产品中心"],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "ITEM-001",
|
||||||
|
"title": "首页视觉与响应式页面",
|
||||||
|
"category": "页面交付",
|
||||||
|
"owner": "林知夏",
|
||||||
|
"status": "已完成",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": "套",
|
||||||
|
"dueDate": "2026-08-12",
|
||||||
|
"description": "包含桌面端、平板端和移动端页面,已完成产品与设计验收。",
|
||||||
|
"images": [
|
||||||
|
{ "id": "IMG-001", "name": "首页视觉预览.png", "size": "638 KB", "url": "/og.png" }
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{ "id": "FILE-001", "name": "首页验收说明.pdf", "size": "1.2 MB", "type": "PDF", "url": "" },
|
||||||
|
{ "id": "FILE-002", "name": "切图资源清单.xlsx", "size": "286 KB", "type": "XLSX", "url": "" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ITEM-002",
|
||||||
|
"title": "动漫作品详情页",
|
||||||
|
"category": "页面交付",
|
||||||
|
"owner": "周砚",
|
||||||
|
"status": "待确认",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": "页",
|
||||||
|
"dueDate": "2026-08-14",
|
||||||
|
"description": "作品资料、剧集、商品和评论区的完整交互页面。",
|
||||||
|
"images": [
|
||||||
|
{ "id": "IMG-002", "name": "星海回响主视觉.jpg", "size": "2.1 MB", "url": "/anime/star-echoes-hero.png" },
|
||||||
|
{ "id": "IMG-003", "name": "周边商品陈列.jpg", "size": "2.1 MB", "url": "/anime/star-echoes-merch.png" }
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{ "id": "FILE-003", "name": "页面字段说明.md", "size": "18 KB", "type": "MD", "url": "" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ITEM-003",
|
||||||
|
"title": "前端部署与使用说明",
|
||||||
|
"category": "说明文档",
|
||||||
|
"owner": "陈默",
|
||||||
|
"status": "进行中",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit": "份",
|
||||||
|
"dueDate": "2026-08-16",
|
||||||
|
"description": "整理本地启动、JSON 数据替换和页面配置方式。",
|
||||||
|
"images": [],
|
||||||
|
"files": [
|
||||||
|
{ "id": "FILE-004", "name": "本地运行说明.docx", "size": "94 KB", "type": "DOCX", "url": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FORM-202608-012",
|
||||||
|
"title": "体验店每周巡检记录",
|
||||||
|
"code": "STORE-W32",
|
||||||
|
"status": "已提交",
|
||||||
|
"updatedAt": "昨天 18:20",
|
||||||
|
"createdBy": "唐可",
|
||||||
|
"tags": ["巡检", "第 32 周"],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "ITEM-101",
|
||||||
|
"title": "入口展示区域",
|
||||||
|
"category": "空间检查",
|
||||||
|
"owner": "唐可",
|
||||||
|
"status": "已完成",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": "处",
|
||||||
|
"dueDate": "2026-08-08",
|
||||||
|
"description": "灯箱、海报和入口动线均符合陈列标准。",
|
||||||
|
"images": [{ "id": "IMG-101", "name": "入口陈列.jpg", "size": "638 KB", "url": "/og.png" }],
|
||||||
|
"files": [{ "id": "FILE-101", "name": "巡检签字表.pdf", "size": "420 KB", "type": "PDF", "url": "" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FORM-202608-006",
|
||||||
|
"title": "秋季活动物料采购",
|
||||||
|
"code": "PURCHASE-AUTUMN",
|
||||||
|
"status": "草稿",
|
||||||
|
"updatedAt": "08-06 11:30",
|
||||||
|
"createdBy": "顾言",
|
||||||
|
"tags": ["采购", "秋季活动"],
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
122
app/data/seasonal-calendar.json
Normal file
122
app/data/seasonal-calendar.json
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"title": "时令与事件日历",
|
||||||
|
"subtitle": "用一张日历查看食材应季期、采购窗口与阶段性安排",
|
||||||
|
"defaultDate": "2026-08-12",
|
||||||
|
"categories": [
|
||||||
|
{ "id": "fruit", "name": "应季水果", "icon": "果", "color": "#e36a48", "soft": "#fff0e9" },
|
||||||
|
{ "id": "seafood", "name": "应季海鲜", "icon": "鲜", "color": "#287ca8", "soft": "#e8f5fb" },
|
||||||
|
{ "id": "activity", "name": "事项安排", "icon": "事", "color": "#6659c7", "soft": "#f0edff" }
|
||||||
|
],
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "fruit-peach",
|
||||||
|
"title": "水蜜桃赏味期",
|
||||||
|
"category": "fruit",
|
||||||
|
"start": "2026-06-15",
|
||||||
|
"end": "2026-08-25",
|
||||||
|
"region": "江浙产区",
|
||||||
|
"bestTime": "7月中旬至8月中旬",
|
||||||
|
"summary": "果香与甜度较好的集中上市阶段,适合鲜食、甜品与礼盒采购。",
|
||||||
|
"tips": "表皮完整、香气自然且轻按微弹的成熟度更合适。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fruit-grape",
|
||||||
|
"title": "阳光玫瑰葡萄",
|
||||||
|
"category": "fruit",
|
||||||
|
"start": "2026-08-01",
|
||||||
|
"end": "2026-09-30",
|
||||||
|
"region": "云南 / 浙江 / 山东",
|
||||||
|
"bestTime": "8月下旬至9月",
|
||||||
|
"summary": "进入集中供应期,可用于记录不同产区的到货与价格变化。",
|
||||||
|
"tips": "优先选择果粒紧实、果梗青绿、整串着色均匀的果品。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fruit-fig",
|
||||||
|
"title": "无花果应季期",
|
||||||
|
"category": "fruit",
|
||||||
|
"start": "2026-07-10",
|
||||||
|
"end": "2026-10-10",
|
||||||
|
"region": "四川 / 山东",
|
||||||
|
"bestTime": "8月至9月",
|
||||||
|
"summary": "成熟后不耐久放,适合在日历中安排短周期采购和快速消耗。",
|
||||||
|
"tips": "果皮无破损、果颈略弯且手感柔软时适合尽快食用。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fruit-pear",
|
||||||
|
"title": "秋月梨预售窗口",
|
||||||
|
"category": "fruit",
|
||||||
|
"start": "2026-08-20",
|
||||||
|
"end": "2026-09-18",
|
||||||
|
"region": "山东 / 河北",
|
||||||
|
"bestTime": "9月上旬",
|
||||||
|
"summary": "从产区预售到集中到货的阶段性窗口,可作为采购提醒示例。",
|
||||||
|
"tips": "查看果形、糖度和冷链发货方式后再确定采购批次。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seafood-crab",
|
||||||
|
"title": "梭子蟹肥美期",
|
||||||
|
"category": "seafood",
|
||||||
|
"start": "2026-08-01",
|
||||||
|
"end": "2026-11-15",
|
||||||
|
"region": "东海沿岸",
|
||||||
|
"bestTime": "8月下旬至10月",
|
||||||
|
"summary": "进入集中上市季,可同步安排采购、菜单上新和库存提醒。",
|
||||||
|
"tips": "选择活力好、蟹壳坚硬、足部完整且分量压手的个体。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seafood-urchin",
|
||||||
|
"title": "海胆品鲜季",
|
||||||
|
"category": "seafood",
|
||||||
|
"start": "2026-05-01",
|
||||||
|
"end": "2026-09-05",
|
||||||
|
"region": "大连 / 福建沿海",
|
||||||
|
"bestTime": "6月至8月",
|
||||||
|
"summary": "不同产区供应期可分别建档,本条作为跨月事件展示。",
|
||||||
|
"tips": "冷藏运输并尽快食用,来源与保存条件应优先确认。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seafood-bass",
|
||||||
|
"title": "海鲈鱼供应旺季",
|
||||||
|
"category": "seafood",
|
||||||
|
"start": "2026-08-08",
|
||||||
|
"end": "2026-10-28",
|
||||||
|
"region": "沿海产区",
|
||||||
|
"bestTime": "8月至10月",
|
||||||
|
"summary": "适合家庭清蒸或餐饮菜单规划,可记录每周报价和规格。",
|
||||||
|
"tips": "鲜鱼以眼睛清亮、鱼鳃鲜红、鱼身有弹性为宜。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seafood-shrimp",
|
||||||
|
"title": "第二季皮皮虾",
|
||||||
|
"category": "seafood",
|
||||||
|
"start": "2026-09-01",
|
||||||
|
"end": "2026-11-10",
|
||||||
|
"region": "渤海 / 黄海",
|
||||||
|
"bestTime": "9月至10月",
|
||||||
|
"summary": "秋季供应时段,用于演示尚未开始但即将在日历中出现的事件。",
|
||||||
|
"tips": "按活力、完整度和运输时间安排到货后的食用顺序。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "activity-buy",
|
||||||
|
"title": "周末鲜品集中采购",
|
||||||
|
"category": "activity",
|
||||||
|
"start": "2026-08-14",
|
||||||
|
"end": "2026-08-16",
|
||||||
|
"region": "线下市场",
|
||||||
|
"bestTime": "08:00—10:30",
|
||||||
|
"summary": "对比本周水果与海鲜价格,补充下周菜单所需食材。",
|
||||||
|
"tips": "提前建立采购清单,并为冷藏食材准备保温袋。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "activity-menu",
|
||||||
|
"title": "九月菜单试做",
|
||||||
|
"category": "activity",
|
||||||
|
"start": "2026-08-24",
|
||||||
|
"end": "2026-08-28",
|
||||||
|
"region": "测试厨房",
|
||||||
|
"bestTime": "每日 14:00",
|
||||||
|
"summary": "结合葡萄、梨与海鲜旺季,完成下一阶段菜单的小批量试做。",
|
||||||
|
"tips": "每次试做记录用量、成本、口感与成品图片。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
41
app/data/star-graph.json
Normal file
41
app/data/star-graph.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"title": "星海创作关系网络",
|
||||||
|
"subtitle": "本地 JSON 演示数据",
|
||||||
|
"nodeTypes": [
|
||||||
|
{ "id": "project", "name": "项目", "color": "#8b7cff" },
|
||||||
|
{ "id": "character", "name": "角色", "color": "#55d7ff" },
|
||||||
|
{ "id": "world", "name": "世界观", "color": "#45e6a8" },
|
||||||
|
{ "id": "organization", "name": "组织", "color": "#ffb45c" },
|
||||||
|
{ "id": "event", "name": "事件", "color": "#ff6f91" }
|
||||||
|
],
|
||||||
|
"nodes": [
|
||||||
|
{ "id": "project-starsea", "label": "星海回响", "type": "project", "x": 0, "y": 0, "color": "#8b7cff", "size": 30, "description": "围绕失落星港展开的原创动画项目。", "tags": ["核心项目", "2026"] },
|
||||||
|
{ "id": "character-mio", "label": "天野澪", "type": "character", "x": -210, "y": -115, "color": "#55d7ff", "size": 23, "description": "能够读取星尘记忆的见习导航员。", "tags": ["主角", "导航员"] },
|
||||||
|
{ "id": "character-ren", "label": "陆沉舟", "type": "character", "x": 205, "y": -120, "color": "#60c5ff", "size": 22, "description": "远航舰队的机械师,负责修复古代航道。", "tags": ["伙伴", "机械师"] },
|
||||||
|
{ "id": "character-aya", "label": "绫音", "type": "character", "x": 250, "y": 105, "color": "#7ce7ff", "size": 20, "description": "来自月背城市的情报交换员。", "tags": ["情报", "月背"] },
|
||||||
|
{ "id": "world-harbor", "label": "第七码头", "type": "world", "x": -235, "y": 120, "color": "#45e6a8", "size": 24, "description": "漂浮在星云边缘的中立贸易港。", "tags": ["地点", "星港"] },
|
||||||
|
{ "id": "world-gate", "label": "静默星门", "type": "world", "x": 30, "y": 205, "color": "#3ed8b0", "size": 22, "description": "沉睡百年的跨星系通道。", "tags": ["遗迹", "航道"] },
|
||||||
|
{ "id": "org-fleet", "label": "白帆舰队", "type": "organization", "x": 410, "y": -20, "color": "#ffb45c", "size": 23, "description": "负责维护外环航线的民间舰队。", "tags": ["组织", "舰队"] },
|
||||||
|
{ "id": "org-observer", "label": "观星议会", "type": "organization", "x": -415, "y": -15, "color": "#ffc36c", "size": 22, "description": "保存旧时代星图的研究机构。", "tags": ["组织", "研究"] },
|
||||||
|
{ "id": "event-blackout", "label": "长夜停航", "type": "event", "x": -125, "y": -285, "color": "#ff6f91", "size": 20, "description": "主航道突然熄灭引发的大规模停航事件。", "tags": ["事件", "序章"] },
|
||||||
|
{ "id": "event-signal", "label": "深空信号", "type": "event", "x": 155, "y": -275, "color": "#ff829e", "size": 20, "description": "来自静默星门另一侧的重复脉冲。", "tags": ["事件", "谜题"] },
|
||||||
|
{ "id": "world-moon", "label": "月背城", "type": "world", "x": 405, "y": 215, "color": "#66dfb5", "size": 21, "description": "建在永夜陨石坑中的地下都市。", "tags": ["地点", "城市"] },
|
||||||
|
{ "id": "event-memory", "label": "星尘记忆", "type": "event", "x": -385, "y": 250, "color": "#ff7d9d", "size": 19, "description": "散落在航道中的古代记录碎片。", "tags": ["线索", "记忆"] }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "id": "edge-1", "source": "project-starsea", "target": "character-mio", "label": "主角", "type": "角色关系" },
|
||||||
|
{ "id": "edge-2", "source": "project-starsea", "target": "character-ren", "label": "伙伴", "type": "角色关系" },
|
||||||
|
{ "id": "edge-3", "source": "project-starsea", "target": "world-harbor", "label": "故事起点", "type": "地点关系" },
|
||||||
|
{ "id": "edge-4", "source": "project-starsea", "target": "world-gate", "label": "核心谜题", "type": "剧情关系" },
|
||||||
|
{ "id": "edge-5", "source": "character-mio", "target": "event-memory", "label": "能够读取", "type": "能力关系" },
|
||||||
|
{ "id": "edge-6", "source": "character-ren", "target": "org-fleet", "label": "所属", "type": "组织关系" },
|
||||||
|
{ "id": "edge-7", "source": "character-aya", "target": "world-moon", "label": "来自", "type": "地点关系" },
|
||||||
|
{ "id": "edge-8", "source": "character-aya", "target": "org-fleet", "label": "提供情报", "type": "协作关系" },
|
||||||
|
{ "id": "edge-9", "source": "org-observer", "target": "event-memory", "label": "收集", "type": "研究关系" },
|
||||||
|
{ "id": "edge-10", "source": "org-observer", "target": "event-blackout", "label": "调查", "type": "事件关系" },
|
||||||
|
{ "id": "edge-11", "source": "event-blackout", "target": "event-signal", "label": "同时发生", "type": "时间关系" },
|
||||||
|
{ "id": "edge-12", "source": "event-signal", "target": "world-gate", "label": "信号来源", "type": "线索关系" },
|
||||||
|
{ "id": "edge-13", "source": "world-harbor", "target": "org-fleet", "label": "补给基地", "type": "组织关系" },
|
||||||
|
{ "id": "edge-14", "source": "world-gate", "target": "world-moon", "label": "旧航线", "type": "地点关系" }
|
||||||
|
]
|
||||||
|
}
|
||||||
195
app/data/web-icons.json
Normal file
195
app/data/web-icons.json
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{ "id": "all", "name": "全部图标" },
|
||||||
|
{ "id": "navigation", "name": "导航与方向" },
|
||||||
|
{ "id": "actions", "name": "常用操作" },
|
||||||
|
{ "id": "status", "name": "状态提示" },
|
||||||
|
{ "id": "media", "name": "媒体控制" },
|
||||||
|
{ "id": "files", "name": "文件内容" },
|
||||||
|
{ "id": "business", "name": "业务界面" },
|
||||||
|
{ "id": "communication", "name": "沟通联系" },
|
||||||
|
{ "id": "identity", "name": "性别与人物" },
|
||||||
|
{ "id": "biology", "name": "生物与自然" },
|
||||||
|
{ "id": "breeding", "name": "繁殖行为提示" },
|
||||||
|
{ "id": "pets", "name": "宠物标识" }
|
||||||
|
],
|
||||||
|
"icons": [
|
||||||
|
{ "id": "home", "name": "Home", "cn": "首页", "symbol": "⌂", "category": "navigation", "keywords": ["home", "首页", "主页"] },
|
||||||
|
{ "id": "menu", "name": "Menu", "cn": "菜单", "symbol": "☰", "category": "navigation", "keywords": ["menu", "菜单", "导航"] },
|
||||||
|
{ "id": "grid", "name": "Grid", "cn": "网格", "symbol": "▦", "category": "navigation", "keywords": ["grid", "网格", "应用"] },
|
||||||
|
{ "id": "list", "name": "List", "cn": "列表", "symbol": "☷", "category": "navigation", "keywords": ["list", "列表", "目录"] },
|
||||||
|
{ "id": "search", "name": "Search", "cn": "搜索", "symbol": "⌕", "category": "navigation", "keywords": ["search", "find", "搜索", "查找"] },
|
||||||
|
{ "id": "arrow-left", "name": "Arrow left", "cn": "向左", "symbol": "←", "category": "navigation", "keywords": ["left", "back", "左", "返回"] },
|
||||||
|
{ "id": "arrow-right", "name": "Arrow right", "cn": "向右", "symbol": "→", "category": "navigation", "keywords": ["right", "next", "右", "下一步"] },
|
||||||
|
{ "id": "arrow-up", "name": "Arrow up", "cn": "向上", "symbol": "↑", "category": "navigation", "keywords": ["up", "上", "顶部"] },
|
||||||
|
{ "id": "arrow-down", "name": "Arrow down", "cn": "向下", "symbol": "↓", "category": "navigation", "keywords": ["down", "下", "展开"] },
|
||||||
|
{ "id": "chevron-left", "name": "Chevron left", "cn": "左箭头", "symbol": "‹", "category": "navigation", "keywords": ["chevron", "previous", "上一页"] },
|
||||||
|
{ "id": "chevron-right", "name": "Chevron right", "cn": "右箭头", "symbol": "›", "category": "navigation", "keywords": ["chevron", "next", "下一页"] },
|
||||||
|
{ "id": "external", "name": "External link", "cn": "外部链接", "symbol": "↗", "category": "navigation", "keywords": ["external", "open", "外链", "打开"] },
|
||||||
|
{ "id": "location", "name": "Location", "cn": "定位", "symbol": "⌖", "category": "navigation", "keywords": ["location", "position", "定位", "位置"] },
|
||||||
|
{ "id": "fullscreen", "name": "Fullscreen", "cn": "全屏", "symbol": "⛶", "category": "navigation", "keywords": ["fullscreen", "expand", "全屏", "放大"] },
|
||||||
|
|
||||||
|
{ "id": "add", "name": "Add", "cn": "添加", "symbol": "+", "category": "actions", "keywords": ["add", "plus", "添加", "新增"] },
|
||||||
|
{ "id": "remove", "name": "Remove", "cn": "减少", "symbol": "−", "category": "actions", "keywords": ["remove", "minus", "减少", "移除"] },
|
||||||
|
{ "id": "close", "name": "Close", "cn": "关闭", "symbol": "×", "category": "actions", "keywords": ["close", "cancel", "关闭", "取消"] },
|
||||||
|
{ "id": "check", "name": "Check", "cn": "确认", "symbol": "✓", "category": "actions", "keywords": ["check", "done", "确认", "完成"] },
|
||||||
|
{ "id": "edit", "name": "Edit", "cn": "编辑", "symbol": "✎", "category": "actions", "keywords": ["edit", "write", "编辑", "修改"] },
|
||||||
|
{ "id": "delete", "name": "Delete", "cn": "删除", "symbol": "⌫", "category": "actions", "keywords": ["delete", "trash", "删除", "清除"] },
|
||||||
|
{ "id": "copy", "name": "Copy", "cn": "复制", "symbol": "▣", "category": "actions", "keywords": ["copy", "duplicate", "复制", "副本"] },
|
||||||
|
{ "id": "download", "name": "Download", "cn": "下载", "symbol": "⇩", "category": "actions", "keywords": ["download", "save", "下载", "保存"] },
|
||||||
|
{ "id": "upload", "name": "Upload", "cn": "上传", "symbol": "⇧", "category": "actions", "keywords": ["upload", "上传", "导入"] },
|
||||||
|
{ "id": "refresh", "name": "Refresh", "cn": "刷新", "symbol": "↻", "category": "actions", "keywords": ["refresh", "reload", "刷新", "重载"] },
|
||||||
|
{ "id": "undo", "name": "Undo", "cn": "撤销", "symbol": "↶", "category": "actions", "keywords": ["undo", "撤销", "返回"] },
|
||||||
|
{ "id": "redo", "name": "Redo", "cn": "重做", "symbol": "↷", "category": "actions", "keywords": ["redo", "重做", "恢复"] },
|
||||||
|
{ "id": "filter", "name": "Filter", "cn": "筛选", "symbol": "▽", "category": "actions", "keywords": ["filter", "筛选", "过滤"] },
|
||||||
|
{ "id": "settings", "name": "Settings", "cn": "设置", "symbol": "⚙", "category": "actions", "keywords": ["settings", "config", "设置", "配置"] },
|
||||||
|
{ "id": "link", "name": "Link", "cn": "链接", "symbol": "⌁", "category": "actions", "keywords": ["link", "链接", "关联"] },
|
||||||
|
{ "id": "bookmark", "name": "Bookmark", "cn": "书签", "symbol": "⌑", "category": "actions", "keywords": ["bookmark", "save", "书签", "收藏"] },
|
||||||
|
{ "id": "heart", "name": "Heart", "cn": "喜欢", "symbol": "♡", "category": "actions", "keywords": ["heart", "like", "喜欢", "收藏"] },
|
||||||
|
{ "id": "star", "name": "Star", "cn": "星标", "symbol": "☆", "category": "actions", "keywords": ["star", "favorite", "星标", "收藏"] },
|
||||||
|
|
||||||
|
{ "id": "success", "name": "Success", "cn": "成功", "symbol": "◉", "category": "status", "keywords": ["success", "ok", "成功", "在线"] },
|
||||||
|
{ "id": "info", "name": "Information", "cn": "信息", "symbol": "ⓘ", "category": "status", "keywords": ["info", "help", "信息", "提示"] },
|
||||||
|
{ "id": "warning", "name": "Warning", "cn": "警告", "symbol": "△", "category": "status", "keywords": ["warning", "alert", "警告", "注意"] },
|
||||||
|
{ "id": "error", "name": "Error", "cn": "错误", "symbol": "⊗", "category": "status", "keywords": ["error", "failed", "错误", "失败"] },
|
||||||
|
{ "id": "pending", "name": "Pending", "cn": "等待", "symbol": "◷", "category": "status", "keywords": ["pending", "time", "等待", "处理中"] },
|
||||||
|
{ "id": "online", "name": "Online", "cn": "在线", "symbol": "●", "category": "status", "keywords": ["online", "active", "在线", "启用"] },
|
||||||
|
{ "id": "offline", "name": "Offline", "cn": "离线", "symbol": "○", "category": "status", "keywords": ["offline", "inactive", "离线", "停用"] },
|
||||||
|
{ "id": "locked", "name": "Locked", "cn": "锁定", "symbol": "⊠", "category": "status", "keywords": ["lock", "private", "锁定", "私密"] },
|
||||||
|
|
||||||
|
{ "id": "play", "name": "Play", "cn": "播放", "symbol": "▶", "category": "media", "keywords": ["play", "播放", "开始"] },
|
||||||
|
{ "id": "pause", "name": "Pause", "cn": "暂停", "symbol": "Ⅱ", "category": "media", "keywords": ["pause", "暂停"] },
|
||||||
|
{ "id": "stop", "name": "Stop", "cn": "停止", "symbol": "■", "category": "media", "keywords": ["stop", "停止", "结束"] },
|
||||||
|
{ "id": "previous", "name": "Previous", "cn": "上一个", "symbol": "◀", "category": "media", "keywords": ["previous", "back", "上一个"] },
|
||||||
|
{ "id": "next", "name": "Next", "cn": "下一个", "symbol": "▶", "category": "media", "keywords": ["next", "forward", "下一个"] },
|
||||||
|
{ "id": "record", "name": "Record", "cn": "录制", "symbol": "◎", "category": "media", "keywords": ["record", "录制", "录像"] },
|
||||||
|
{ "id": "volume", "name": "Volume", "cn": "音量", "symbol": "◖", "category": "media", "keywords": ["volume", "sound", "音量", "声音"] },
|
||||||
|
{ "id": "image", "name": "Image", "cn": "图片", "symbol": "▧", "category": "media", "keywords": ["image", "photo", "图片", "照片"] },
|
||||||
|
|
||||||
|
{ "id": "file", "name": "File", "cn": "文件", "symbol": "□", "category": "files", "keywords": ["file", "文件", "附件"] },
|
||||||
|
{ "id": "folder", "name": "Folder", "cn": "文件夹", "symbol": "▱", "category": "files", "keywords": ["folder", "目录", "文件夹"] },
|
||||||
|
{ "id": "document", "name": "Document", "cn": "文档", "symbol": "▤", "category": "files", "keywords": ["document", "文档", "文章"] },
|
||||||
|
{ "id": "table", "name": "Table", "cn": "表格", "symbol": "▦", "category": "files", "keywords": ["table", "sheet", "表格", "数据"] },
|
||||||
|
{ "id": "archive", "name": "Archive", "cn": "归档", "symbol": "▰", "category": "files", "keywords": ["archive", "box", "归档", "压缩包"] },
|
||||||
|
{ "id": "code", "name": "Code", "cn": "代码", "symbol": "‹/›", "category": "files", "keywords": ["code", "developer", "代码", "开发"] },
|
||||||
|
|
||||||
|
{ "id": "user", "name": "User", "cn": "用户", "symbol": "♙", "category": "business", "keywords": ["user", "person", "用户", "人员"] },
|
||||||
|
{ "id": "team", "name": "Team", "cn": "团队", "symbol": "♙♙", "category": "business", "keywords": ["team", "users", "团队", "成员"] },
|
||||||
|
{ "id": "calendar", "name": "Calendar", "cn": "日历", "symbol": "▦", "category": "business", "keywords": ["calendar", "date", "日历", "日期"] },
|
||||||
|
{ "id": "clock", "name": "Clock", "cn": "时间", "symbol": "◷", "category": "business", "keywords": ["clock", "time", "时间", "定时"] },
|
||||||
|
{ "id": "tag", "name": "Tag", "cn": "标签", "symbol": "◇", "category": "business", "keywords": ["tag", "label", "标签", "分类"] },
|
||||||
|
{ "id": "cart", "name": "Cart", "cn": "购物车", "symbol": "♧", "category": "business", "keywords": ["cart", "shop", "购物车", "商品"] },
|
||||||
|
{ "id": "chart", "name": "Chart", "cn": "图表", "symbol": "▥", "category": "business", "keywords": ["chart", "analytics", "图表", "统计"] },
|
||||||
|
{ "id": "layers", "name": "Layers", "cn": "图层", "symbol": "▱", "category": "business", "keywords": ["layers", "stack", "图层", "层级"] },
|
||||||
|
{ "id": "briefcase", "name": "Briefcase", "cn": "工作", "symbol": "▣", "category": "business", "keywords": ["briefcase", "work", "工作", "项目"] },
|
||||||
|
|
||||||
|
{ "id": "mail", "name": "Mail", "cn": "邮件", "symbol": "✉", "category": "communication", "keywords": ["mail", "email", "邮件", "消息"] },
|
||||||
|
{ "id": "phone", "name": "Phone", "cn": "电话", "symbol": "☎", "category": "communication", "keywords": ["phone", "call", "电话", "联系"] },
|
||||||
|
{ "id": "comment", "name": "Comment", "cn": "评论", "symbol": "◰", "category": "communication", "keywords": ["comment", "review", "评论", "回复"] },
|
||||||
|
{ "id": "chat", "name": "Chat", "cn": "聊天", "symbol": "▱", "category": "communication", "keywords": ["chat", "message", "聊天", "会话"] },
|
||||||
|
{ "id": "send", "name": "Send", "cn": "发送", "symbol": "➤", "category": "communication", "keywords": ["send", "发送", "提交"] },
|
||||||
|
{ "id": "at", "name": "At sign", "cn": "提及", "symbol": "@", "category": "communication", "keywords": ["at", "mention", "提及", "账号"] },
|
||||||
|
{ "id": "notification", "name": "Notification", "cn": "通知", "symbol": "♢", "category": "communication", "keywords": ["notification", "bell", "通知", "提醒"] },
|
||||||
|
|
||||||
|
{ "id": "male", "name": "Male", "cn": "男性", "symbol": "♂", "category": "identity", "keywords": ["male", "man", "boy", "男性", "男生", "男"] },
|
||||||
|
{ "id": "female", "name": "Female", "cn": "女性", "symbol": "♀", "category": "identity", "keywords": ["female", "woman", "girl", "女性", "女生", "女"] },
|
||||||
|
{ "id": "gender-combined", "name": "Gender combined", "cn": "男女组合", "symbol": "⚥", "category": "identity", "keywords": ["gender", "male", "female", "性别", "男女", "组合"] },
|
||||||
|
{ "id": "female-pair", "name": "Female pair", "cn": "女性组合", "symbol": "⚢", "category": "identity", "keywords": ["female", "pair", "women", "女性", "组合"] },
|
||||||
|
{ "id": "male-pair", "name": "Male pair", "cn": "男性组合", "symbol": "⚣", "category": "identity", "keywords": ["male", "pair", "men", "男性", "组合"] },
|
||||||
|
{ "id": "transgender", "name": "Transgender", "cn": "跨性别", "symbol": "⚧", "category": "identity", "keywords": ["transgender", "gender", "跨性别", "性别"] },
|
||||||
|
{ "id": "gender-neutral", "name": "Gender neutral", "cn": "中性标识", "symbol": "⚲", "category": "identity", "keywords": ["neutral", "gender", "中性", "性别"] },
|
||||||
|
{ "id": "person-outline", "name": "Person outline", "cn": "人物", "symbol": "♙", "category": "identity", "keywords": ["person", "people", "人物", "用户", "人员"] },
|
||||||
|
{ "id": "woman-profile", "name": "Woman profile", "cn": "女性人物", "symbol": "◯♀", "category": "identity", "keywords": ["woman", "profile", "女性", "人物", "头像"] },
|
||||||
|
{ "id": "man-profile", "name": "Man profile", "cn": "男性人物", "symbol": "◯♂", "category": "identity", "keywords": ["man", "profile", "男性", "人物", "头像"] },
|
||||||
|
{ "id": "couple", "name": "Couple", "cn": "男女人物", "symbol": "♀♂", "category": "identity", "keywords": ["couple", "people", "男女", "人物", "伴侣"] },
|
||||||
|
{ "id": "family", "name": "Family", "cn": "家庭成员", "symbol": "♀♂♙", "category": "identity", "keywords": ["family", "people", "家庭", "成员", "人物"] },
|
||||||
|
|
||||||
|
{ "id": "atom", "name": "Atom", "cn": "原子", "symbol": "⚛", "category": "biology", "keywords": ["atom", "science", "原子", "科学", "生物"] },
|
||||||
|
{ "id": "biohazard", "name": "Biohazard", "cn": "生物安全", "symbol": "☣", "category": "biology", "keywords": ["biohazard", "safety", "生物", "安全", "危险"] },
|
||||||
|
{ "id": "medical", "name": "Medical", "cn": "医疗", "symbol": "⚕", "category": "biology", "keywords": ["medical", "health", "医疗", "健康", "生命"] },
|
||||||
|
{ "id": "laboratory", "name": "Laboratory", "cn": "实验室", "symbol": "⚗", "category": "biology", "keywords": ["laboratory", "chemistry", "实验室", "化学", "研究"] },
|
||||||
|
{ "id": "cell", "name": "Cell", "cn": "细胞", "symbol": "◉", "category": "biology", "keywords": ["cell", "biology", "细胞", "生物", "生命"] },
|
||||||
|
{ "id": "molecule", "name": "Molecule", "cn": "分子结构", "symbol": "⬡", "category": "biology", "keywords": ["molecule", "structure", "分子", "结构", "化学"] },
|
||||||
|
{ "id": "gene", "name": "Gene", "cn": "基因链", "symbol": "⌁⌁", "category": "biology", "keywords": ["gene", "dna", "基因", "遗传", "生物"] },
|
||||||
|
{ "id": "microbe", "name": "Microbe", "cn": "微生物", "symbol": "✣", "category": "biology", "keywords": ["microbe", "bacteria", "微生物", "细菌"] },
|
||||||
|
{ "id": "heartbeat", "name": "Heartbeat", "cn": "生命波形", "symbol": "∿", "category": "biology", "keywords": ["heartbeat", "life", "心跳", "生命", "波形"] },
|
||||||
|
{ "id": "health-cross", "name": "Health cross", "cn": "健康标识", "symbol": "✚", "category": "biology", "keywords": ["health", "cross", "健康", "医疗", "急救"] },
|
||||||
|
{ "id": "flower", "name": "Flower", "cn": "花朵", "symbol": "✿", "category": "biology", "keywords": ["flower", "nature", "花朵", "植物", "自然"] },
|
||||||
|
{ "id": "blossom", "name": "Blossom", "cn": "花纹", "symbol": "❀", "category": "biology", "keywords": ["blossom", "pattern", "花纹", "花朵", "图案"] },
|
||||||
|
{ "id": "leaf", "name": "Leaf", "cn": "叶片", "symbol": "❧", "category": "biology", "keywords": ["leaf", "plant", "叶片", "植物", "绿色"] },
|
||||||
|
{ "id": "tree", "name": "Tree", "cn": "树木", "symbol": "♣", "category": "biology", "keywords": ["tree", "forest", "树木", "森林", "植物"] },
|
||||||
|
{ "id": "seed", "name": "Seed", "cn": "种子", "symbol": "◇", "category": "biology", "keywords": ["seed", "grow", "种子", "成长", "植物"] },
|
||||||
|
{ "id": "sun", "name": "Sun", "cn": "太阳", "symbol": "☼", "category": "biology", "keywords": ["sun", "light", "太阳", "阳光", "自然"] },
|
||||||
|
{ "id": "moon", "name": "Moon", "cn": "月亮", "symbol": "☾", "category": "biology", "keywords": ["moon", "night", "月亮", "夜晚", "自然"] },
|
||||||
|
{ "id": "earth", "name": "Earth", "cn": "生态地球", "symbol": "⊕", "category": "biology", "keywords": ["earth", "ecology", "地球", "生态", "环境"] },
|
||||||
|
{ "id": "water", "name": "Water", "cn": "水波", "symbol": "≋", "category": "biology", "keywords": ["water", "wave", "水", "水波", "自然"] },
|
||||||
|
{ "id": "butterfly", "name": "Butterfly", "cn": "蝴蝶图案", "symbol": "ƸӜƷ", "category": "biology", "keywords": ["butterfly", "insect", "蝴蝶", "昆虫", "图案"] },
|
||||||
|
{ "id": "fish", "name": "Fish", "cn": "鱼类图案", "symbol": "><>", "category": "biology", "keywords": ["fish", "animal", "鱼", "动物", "水生"] },
|
||||||
|
{ "id": "cat", "name": "Cat", "cn": "猫咪图案", "symbol": "=^·^=", "category": "biology", "keywords": ["cat", "animal", "猫", "宠物", "动物"] },
|
||||||
|
{ "id": "ecology", "name": "Ecology", "cn": "生态循环", "symbol": "♻", "category": "biology", "keywords": ["ecology", "recycle", "生态", "循环", "环保"] },
|
||||||
|
|
||||||
|
{ "id": "courtship", "name": "Courtship", "cn": "求偶行为", "symbol": "♂♡♀", "category": "breeding", "keywords": ["courtship", "求偶", "繁殖", "交配", "动物"] },
|
||||||
|
{ "id": "breeding-pair", "name": "Breeding pair", "cn": "配对繁殖", "symbol": "♂♀", "category": "breeding", "keywords": ["pair", "breeding", "配对", "繁殖", "交配"] },
|
||||||
|
{ "id": "reverse-pair", "name": "Reverse pair", "cn": "反向配对", "symbol": "♀♂", "category": "breeding", "keywords": ["pair", "direction", "反向", "配对", "姿势提示"] },
|
||||||
|
{ "id": "pair-bond", "name": "Pair bond", "cn": "配偶关系", "symbol": "♂⌁♀", "category": "breeding", "keywords": ["bond", "pair", "配偶", "关系", "繁殖"] },
|
||||||
|
{ "id": "mating-symbol", "name": "Mating symbol", "cn": "繁殖行为", "symbol": "⚥", "category": "breeding", "keywords": ["mating", "breeding", "繁殖", "交配", "行为"] },
|
||||||
|
{ "id": "breeding-season", "name": "Breeding season", "cn": "繁殖期", "symbol": "⚥◷", "category": "breeding", "keywords": ["season", "time", "繁殖期", "发情期", "时间"] },
|
||||||
|
{ "id": "breeding-location", "name": "Breeding location", "cn": "繁殖区域", "symbol": "⚥⌖", "category": "breeding", "keywords": ["location", "zone", "繁殖区", "配种区", "位置"] },
|
||||||
|
{ "id": "breeding-ready", "name": "Breeding ready", "cn": "可进行繁殖", "symbol": "⚥✓", "category": "breeding", "keywords": ["ready", "available", "可繁殖", "准备", "配种"] },
|
||||||
|
{ "id": "breeding-observe", "name": "Observe breeding", "cn": "繁殖观察", "symbol": "⚥◉", "category": "breeding", "keywords": ["observe", "monitor", "繁殖观察", "监测", "记录"] },
|
||||||
|
{ "id": "do-not-disturb", "name": "Do not disturb", "cn": "繁殖勿扰", "symbol": "⚥⊘", "category": "breeding", "keywords": ["do not disturb", "quiet", "勿扰", "繁殖", "警示"] },
|
||||||
|
{ "id": "separate-pair", "name": "Separate pair", "cn": "分开饲养", "symbol": "♂│♀", "category": "breeding", "keywords": ["separate", "apart", "分开", "隔离", "饲养"] },
|
||||||
|
{ "id": "safe-distance", "name": "Safe distance", "cn": "保持距离", "symbol": "♂↔♀", "category": "breeding", "keywords": ["distance", "space", "距离", "观察", "提示"] },
|
||||||
|
{ "id": "one-male-group", "name": "One male group", "cn": "一雄多雌", "symbol": "♂♀♀", "category": "breeding", "keywords": ["group", "male", "female", "一雄多雌", "配种组合"] },
|
||||||
|
{ "id": "one-female-group", "name": "One female group", "cn": "多雄一雌", "symbol": "♂♂♀", "category": "breeding", "keywords": ["group", "male", "female", "多雄一雌", "配种组合"] },
|
||||||
|
{ "id": "genetic-match", "name": "Genetic match", "cn": "遗传匹配", "symbol": "♂⌁♀", "category": "breeding", "keywords": ["genetic", "match", "遗传", "配对", "选育"] },
|
||||||
|
{ "id": "breeding-health", "name": "Breeding health", "cn": "繁殖健康检查", "symbol": "⚕⚥", "category": "breeding", "keywords": ["health", "check", "健康检查", "繁殖", "兽医"] },
|
||||||
|
{ "id": "pregnancy", "name": "Pregnancy", "cn": "妊娠期", "symbol": "♀○", "category": "breeding", "keywords": ["pregnancy", "gestation", "妊娠", "孕期", "繁殖"] },
|
||||||
|
{ "id": "nesting", "name": "Nesting", "cn": "筑巢期", "symbol": "⚥⌂", "category": "breeding", "keywords": ["nest", "nesting", "筑巢", "繁殖", "鸟类"] },
|
||||||
|
{ "id": "eggs", "name": "Eggs", "cn": "产卵记录", "symbol": "○○○", "category": "breeding", "keywords": ["eggs", "spawn", "产卵", "繁殖", "记录"] },
|
||||||
|
{ "id": "offspring", "name": "Offspring", "cn": "后代出生", "symbol": "⚥·", "category": "breeding", "keywords": ["offspring", "birth", "后代", "出生", "幼体"] },
|
||||||
|
{ "id": "breeding-complete", "name": "Breeding complete", "cn": "繁殖完成", "symbol": "⚥✓", "category": "breeding", "keywords": ["complete", "success", "繁殖完成", "成功", "记录"] },
|
||||||
|
{ "id": "breeding-failed", "name": "Breeding failed", "cn": "繁殖未成功", "symbol": "⚥×", "category": "breeding", "keywords": ["failed", "unsuccessful", "繁殖失败", "未成功", "记录"] },
|
||||||
|
{ "id": "breeding-rest", "name": "Breeding rest", "cn": "繁殖休息期", "symbol": "⚥Ⅱ", "category": "breeding", "keywords": ["rest", "pause", "休息期", "间隔", "繁殖"] },
|
||||||
|
{ "id": "breeding-record", "name": "Breeding record", "cn": "繁殖记录", "symbol": "⚥▤", "category": "breeding", "keywords": ["record", "document", "繁殖记录", "配种记录", "档案"] },
|
||||||
|
|
||||||
|
{ "id": "pet-dog-face", "name": "Dog face", "cn": "宠物狗", "symbol": "🐶︎", "category": "pets", "keywords": ["dog", "puppy", "狗", "犬", "宠物"] },
|
||||||
|
{ "id": "pet-dog", "name": "Dog", "cn": "犬类", "symbol": "🐕︎", "category": "pets", "keywords": ["dog", "canine", "狗", "犬类", "宠物"] },
|
||||||
|
{ "id": "pet-guide-dog", "name": "Guide dog", "cn": "导盲犬", "symbol": "🦮︎", "category": "pets", "keywords": ["guide dog", "service", "导盲犬", "工作犬", "犬"] },
|
||||||
|
{ "id": "pet-cat-face", "name": "Cat face", "cn": "宠物猫", "symbol": "🐱︎", "category": "pets", "keywords": ["cat", "kitten", "猫", "猫咪", "宠物"] },
|
||||||
|
{ "id": "pet-cat", "name": "Cat", "cn": "猫科宠物", "symbol": "🐈︎", "category": "pets", "keywords": ["cat", "feline", "猫", "猫科", "宠物"] },
|
||||||
|
{ "id": "pet-rabbit", "name": "Rabbit", "cn": "兔子", "symbol": "🐇︎", "category": "pets", "keywords": ["rabbit", "bunny", "兔子", "兔", "宠物"] },
|
||||||
|
{ "id": "pet-hamster", "name": "Hamster", "cn": "仓鼠", "symbol": "🐹︎", "category": "pets", "keywords": ["hamster", "rodent", "仓鼠", "小宠", "宠物"] },
|
||||||
|
{ "id": "pet-guinea-pig", "name": "Guinea pig", "cn": "豚鼠", "symbol": "(•ᴥ•)", "category": "pets", "keywords": ["guinea pig", "cavy", "豚鼠", "荷兰猪", "小宠"] },
|
||||||
|
{ "id": "pet-chinchilla", "name": "Chinchilla", "cn": "龙猫", "symbol": "(•ω•)", "category": "pets", "keywords": ["chinchilla", "龙猫", "毛丝鼠", "小宠"] },
|
||||||
|
{ "id": "pet-mouse", "name": "Mouse", "cn": "宠物鼠", "symbol": "🐁︎", "category": "pets", "keywords": ["mouse", "rodent", "鼠", "宠物鼠", "小宠"] },
|
||||||
|
{ "id": "pet-rat", "name": "Rat", "cn": "花枝鼠", "symbol": "🐀︎", "category": "pets", "keywords": ["rat", "fancy rat", "花枝鼠", "宠物鼠", "小宠"] },
|
||||||
|
{ "id": "pet-hedgehog", "name": "Hedgehog", "cn": "刺猬", "symbol": "🦔︎", "category": "pets", "keywords": ["hedgehog", "刺猬", "异宠", "小宠"] },
|
||||||
|
{ "id": "pet-ferret", "name": "Ferret", "cn": "雪貂", "symbol": "〜•", "category": "pets", "keywords": ["ferret", "weasel", "雪貂", "貂", "异宠"] },
|
||||||
|
{ "id": "pet-squirrel", "name": "Squirrel", "cn": "松鼠", "symbol": "🐿︎", "category": "pets", "keywords": ["squirrel", "松鼠", "小宠", "动物"] },
|
||||||
|
{ "id": "pet-parrot", "name": "Parrot", "cn": "鹦鹉", "symbol": "🦜︎", "category": "pets", "keywords": ["parrot", "鹦鹉", "鸟类", "宠物鸟"] },
|
||||||
|
{ "id": "pet-bird", "name": "Pet bird", "cn": "宠物鸟", "symbol": "🐦︎", "category": "pets", "keywords": ["bird", "pet bird", "鸟", "宠物鸟", "鸟类"] },
|
||||||
|
{ "id": "pet-dove", "name": "Dove", "cn": "鸽子", "symbol": "🕊︎", "category": "pets", "keywords": ["dove", "pigeon", "鸽子", "信鸽", "鸟类"] },
|
||||||
|
{ "id": "pet-owl", "name": "Owl", "cn": "猫头鹰", "symbol": "🦉︎", "category": "pets", "keywords": ["owl", "猫头鹰", "鸟类", "异宠"] },
|
||||||
|
{ "id": "pet-duck", "name": "Duck", "cn": "鸭子", "symbol": "🦆︎", "category": "pets", "keywords": ["duck", "鸭子", "家禽", "宠物"] },
|
||||||
|
{ "id": "pet-chicken", "name": "Chicken", "cn": "宠物鸡", "symbol": "🐔︎", "category": "pets", "keywords": ["chicken", "hen", "鸡", "家禽", "宠物"] },
|
||||||
|
{ "id": "pet-fish", "name": "Fish", "cn": "观赏鱼", "symbol": "🐟︎", "category": "pets", "keywords": ["fish", "aquarium", "鱼", "观赏鱼", "水族"] },
|
||||||
|
{ "id": "pet-tropical-fish", "name": "Tropical fish", "cn": "热带鱼", "symbol": "🐠︎", "category": "pets", "keywords": ["tropical fish", "aquarium", "热带鱼", "水族", "观赏鱼"] },
|
||||||
|
{ "id": "pet-goldfish", "name": "Goldfish", "cn": "金鱼", "symbol": "><°>", "category": "pets", "keywords": ["goldfish", "fish", "金鱼", "水族", "观赏鱼"] },
|
||||||
|
{ "id": "pet-turtle", "name": "Turtle", "cn": "宠物龟", "symbol": "🐢︎", "category": "pets", "keywords": ["turtle", "tortoise", "乌龟", "宠物龟", "爬宠"] },
|
||||||
|
{ "id": "pet-snake", "name": "Snake", "cn": "宠物蛇", "symbol": "🐍︎", "category": "pets", "keywords": ["snake", "reptile", "蛇", "宠物蛇", "爬宠"] },
|
||||||
|
{ "id": "pet-lizard", "name": "Lizard", "cn": "蜥蜴", "symbol": "🦎︎", "category": "pets", "keywords": ["lizard", "reptile", "蜥蜴", "守宫", "爬宠"] },
|
||||||
|
{ "id": "pet-frog", "name": "Frog", "cn": "宠物蛙", "symbol": "🐸︎", "category": "pets", "keywords": ["frog", "amphibian", "蛙", "宠物蛙", "两栖"] },
|
||||||
|
{ "id": "pet-crab", "name": "Crab", "cn": "宠物蟹", "symbol": "🦀︎", "category": "pets", "keywords": ["crab", "aquatic", "螃蟹", "宠物蟹", "水族"] },
|
||||||
|
{ "id": "pet-shrimp", "name": "Shrimp", "cn": "观赏虾", "symbol": "🦐︎", "category": "pets", "keywords": ["shrimp", "aquarium", "虾", "观赏虾", "水族"] },
|
||||||
|
{ "id": "pet-snail", "name": "Snail", "cn": "蜗牛", "symbol": "🐌︎", "category": "pets", "keywords": ["snail", "蜗牛", "异宠", "软体动物"] },
|
||||||
|
{ "id": "pet-spider", "name": "Spider", "cn": "宠物蜘蛛", "symbol": "🕷︎", "category": "pets", "keywords": ["spider", "tarantula", "蜘蛛", "捕鸟蛛", "异宠"] },
|
||||||
|
{ "id": "pet-scorpion", "name": "Scorpion", "cn": "宠物蝎", "symbol": "🦂︎", "category": "pets", "keywords": ["scorpion", "蝎子", "宠物蝎", "异宠"] },
|
||||||
|
{ "id": "pet-ant", "name": "Ant", "cn": "蚂蚁", "symbol": "🐜︎", "category": "pets", "keywords": ["ant", "蚂蚁", "蚁巢", "昆虫宠物"] },
|
||||||
|
{ "id": "pet-beetle", "name": "Beetle", "cn": "甲虫", "symbol": "🪲︎", "category": "pets", "keywords": ["beetle", "甲虫", "独角仙", "昆虫宠物"] },
|
||||||
|
{ "id": "pet-butterfly", "name": "Butterfly", "cn": "蝴蝶", "symbol": "🦋︎", "category": "pets", "keywords": ["butterfly", "蝴蝶", "昆虫", "自然观察"] },
|
||||||
|
{ "id": "pet-mini-pig", "name": "Mini pig", "cn": "迷你猪", "symbol": "🐖︎", "category": "pets", "keywords": ["pig", "mini pig", "迷你猪", "宠物猪"] },
|
||||||
|
{ "id": "pet-horse", "name": "Horse", "cn": "马匹", "symbol": "🐎︎", "category": "pets", "keywords": ["horse", "equine", "马", "马匹", "伴侣动物"] },
|
||||||
|
{ "id": "pet-goat", "name": "Goat", "cn": "宠物羊", "symbol": "🐐︎", "category": "pets", "keywords": ["goat", "羊", "山羊", "宠物羊"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
199
app/icons/IconLibrary.module.css
Normal file
199
app/icons/IconLibrary.module.css
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
@keyframes toastIn { from { opacity: 0; transform: translate(-50%,10px); } to { opacity: 1; transform: translate(-50%,0); } }
|
||||||
|
|
||||||
|
.page {
|
||||||
|
--ink: #222a3c;
|
||||||
|
--muted: #8e96a5;
|
||||||
|
--line: #e6e8ed;
|
||||||
|
--blue: #4169e1;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.page * { box-sizing: border-box; }
|
||||||
|
.page button, .page input { font: inherit; }
|
||||||
|
.topbar { position: sticky; top: 0; z-index: 30; height: 62px; display: flex; align-items: center; padding: 0 20px; border-bottom: 1px solid #e4e6eb; background: rgba(255,255,255,.96); backdrop-filter: blur(12px); }
|
||||||
|
.brand { display: grid; grid-template-columns: 31px auto; align-items: center; column-gap: 9px; color: #252d40; text-decoration: none; }
|
||||||
|
.brand > span { grid-row: 1/3; width: 31px; height: 31px; display: grid; place-items: center; border-radius: 9px; background: #292f42; color: #fff; font-family: Georgia,serif; font-size: 15px; }
|
||||||
|
.brand strong { align-self: end; font-size: 12px; line-height: 1; }
|
||||||
|
.brand small { align-self: start; margin-top: 3px; color: #a0a5af; font-size: 5px; letter-spacing: 1px; }
|
||||||
|
.topbar nav { height: 100%; display: flex; align-items: stretch; gap: 3px; margin-left: 39px; }
|
||||||
|
.topbar nav a { position: relative; display: flex; align-items: center; padding: 0 13px; color: #8b92a0; text-decoration: none; font-size: 9px; }
|
||||||
|
.topbar nav a:hover, .topbar nav .activeNav { color: #343d52; }
|
||||||
|
.topbar nav .activeNav::after { content: ""; position: absolute; inset: auto 12px -1px; height: 2px; background: var(--blue); }
|
||||||
|
.headerActions { display: flex; align-items: center; gap: 7px; margin-left: auto; }
|
||||||
|
.headerActions button { height: 30px; padding: 0 10px; border: 1px solid #e0e3e9; border-radius: 6px; background: #fff; color: #687285; cursor: pointer; font-size: 8px; }
|
||||||
|
.headerActions button:hover:not(:disabled), .headerActions .favoriteFilterActive { border-color: #b4c4ec; background: #f2f5ff; color: #3d60c1; }
|
||||||
|
.headerActions button:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.headerActions > span { width: 29px; height: 29px; display: grid; place-items: center; margin-left: 4px; border-radius: 50%; background: #e1e8fb; color: #4163ac; font-size: 8px; font-weight: 700; }
|
||||||
|
|
||||||
|
.workspace { display: grid; grid-template-columns: 194px minmax(0,1fr) 308px; align-items: start; min-height: calc(100vh - 62px); }
|
||||||
|
.sidebar { position: sticky; top: 62px; height: calc(100vh - 62px); display: flex; flex-direction: column; border-right: 1px solid #e3e6eb; background: #fff; }
|
||||||
|
.sidebarTitle { padding: 27px 18px 17px; }
|
||||||
|
.sidebarTitle > span { display: block; margin-bottom: 4px; color: #6880bd; font-size: 6px; font-weight: 800; letter-spacing: 1.4px; }
|
||||||
|
.sidebarTitle strong { display: block; font-size: 14px; }
|
||||||
|
.sidebarTitle small { display: block; margin-top: 5px; color: #a0a6b0; font-size: 7px; }
|
||||||
|
.categories { padding: 4px 9px; }
|
||||||
|
.categories button { width: 100%; height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: #737d8f; cursor: pointer; font-size: 8px; text-align: left; }
|
||||||
|
.categories button:hover { background: #f5f6f9; }
|
||||||
|
.categories .categoryActive { background: #edf2ff; color: #385fc5; font-weight: 700; }
|
||||||
|
.categories button > span { width: 20px; color: #929bac; font-family: "Segoe UI Symbol",sans-serif; font-size: 12px; text-align: center; }
|
||||||
|
.categories .categoryActive > span { color: #4169d3; }
|
||||||
|
.categories button > b { margin-left: auto; padding: 2px 5px; border-radius: 8px; background: #f0f1f4; color: #9ca3ae; font-size: 6px; }
|
||||||
|
.categories .categoryActive > b { background: #dce6ff; color: #4770d2; }
|
||||||
|
.sidebarHelp { display: flex; gap: 9px; margin: auto 12px 14px; padding: 12px; border: 1px solid #e5e8ef; border-radius: 9px; background: #f8f9fb; }
|
||||||
|
.sidebarHelp > span { width: 21px; height: 21px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #e5ecfd; color: #4a6fca; font-family: Georgia,serif; font-size: 9px; font-style: italic; }
|
||||||
|
.sidebarHelp p { display: flex; flex-direction: column; gap: 4px; margin: 0; }
|
||||||
|
.sidebarHelp strong { font-size: 7px; }
|
||||||
|
.sidebarHelp small { color: #9da4b0; font-size: 6px; line-height: 1.6; }
|
||||||
|
|
||||||
|
.main { min-width: 0; padding: 23px 25px 70px; }
|
||||||
|
.hero { position: relative; min-height: 145px; display: flex; align-items: center; justify-content: space-between; overflow: hidden; margin-bottom: 15px; padding: 26px 31px; border-radius: 13px; background: radial-gradient(circle at 79% 14%,rgba(124,145,255,.2),transparent 28%),linear-gradient(120deg,#242a3e,#333c59); color: #fff; }
|
||||||
|
.hero > div:first-child { position: relative; z-index: 2; }
|
||||||
|
.hero p { margin: 0 0 6px; color: #aebcff; font-size: 6px; font-weight: 800; letter-spacing: 1.8px; }
|
||||||
|
.hero h1 { margin: 0 0 8px; font-size: 21px; letter-spacing: -.4px; }
|
||||||
|
.hero div > span { color: rgba(255,255,255,.52); font-size: 8px; }
|
||||||
|
.heroSamples { display: flex; align-items: center; gap: 8px; margin-right: 4px; }
|
||||||
|
.heroSamples span { width: 47px; height: 54px; display: grid; place-items: center; border: 1px solid rgba(255,255,255,.14); border-radius: 10px; background: rgba(255,255,255,.08); color: rgba(255,255,255,.8)!important; font-family: "Segoe UI Symbol",sans-serif; font-size: 22px!important; box-shadow: 0 9px 22px rgba(5,8,20,.14); backdrop-filter: blur(5px); }
|
||||||
|
.toolbar { min-height: 58px; display: flex; align-items: center; gap: 14px; padding: 11px 13px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
|
||||||
|
.search { height: 35px; display: flex; align-items: center; gap: 7px; flex: 1; padding: 0 9px; border: 1px solid #e1e4ea; border-radius: 7px; background: #f9fafb; color: #959da9; }
|
||||||
|
.search:focus-within { border-color: #a5b8e9; background: #fff; box-shadow: 0 0 0 3px rgba(65,105,225,.07); }
|
||||||
|
.search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: #475369; font-size: 8px; }
|
||||||
|
.search button { border: 0; background: none; color: #9ca3ae; cursor: pointer; }
|
||||||
|
.search kbd { padding: 3px 5px; border: 1px solid #dfe2e8; border-radius: 4px; background: #fff; color: #a0a5ae; font-family: inherit; font-size: 6px; }
|
||||||
|
.sizeControl { display: flex; align-items: center; gap: 8px; padding-left: 2px; color: #7d8695; font-size: 7px; }
|
||||||
|
.sizeControl input { width: 78px; accent-color: var(--blue); }
|
||||||
|
.sizeControl b { width: 27px; color: #5f697c; font-size: 7px; }
|
||||||
|
.viewSwitch { display: flex; padding: 3px; border-radius: 6px; background: #f1f2f5; }
|
||||||
|
.viewSwitch button { width: 27px; height: 26px; border: 0; border-radius: 4px; background: transparent; color: #9aa1ad; cursor: pointer; }
|
||||||
|
.viewSwitch .viewActive { background: #fff; color: #4667bf; box-shadow: 0 2px 6px rgba(34,39,55,.08); }
|
||||||
|
.resultHeading { min-height: 55px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.resultHeading > div { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.resultHeading strong { font-size: 10px; }
|
||||||
|
.resultHeading span { padding: 3px 6px; border-radius: 8px; background: #e9ecf2; color: #8d95a2; font-size: 6px; }
|
||||||
|
.resultHeading button { border: 0; background: none; color: #5c75bb; cursor: pointer; font-size: 7px; }
|
||||||
|
.iconCollection { --preview-size: 28px; --preview-color: #27324a; display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 8px; }
|
||||||
|
.iconCollection article { position: relative; min-width: 0; height: 126px; overflow: hidden; border: 1px solid #e2e5eb; border-radius: 9px; background: #fff; transition: transform .16s,border .16s,box-shadow .16s; }
|
||||||
|
.iconCollection article:hover { transform: translateY(-2px); border-color: #b8c6e9; box-shadow: 0 8px 22px rgba(39,50,77,.07); }
|
||||||
|
.iconCollection .selectedCard { border-color: #708ee0; box-shadow: inset 0 0 0 1px #708ee0,0 8px 22px rgba(65,105,225,.08); }
|
||||||
|
.selectIcon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; padding: 14px 9px 12px; border: 0; background: transparent; cursor: pointer; }
|
||||||
|
.iconSymbol { min-height: 38px; display: grid; place-items: center; color: var(--preview-color); font-family: "Segoe UI Symbol","Arial Unicode MS",sans-serif; font-size: var(--preview-size); line-height: 1; white-space: nowrap; }
|
||||||
|
.iconLabel { min-width: 0; width: 100%; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.iconLabel strong { overflow: hidden; color: #505a6c; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.iconLabel small { overflow: hidden; color: #a0a6b1; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.favoriteButton { position: absolute; right: 6px; top: 6px; width: 24px; height: 24px; border: 0; border-radius: 5px; background: transparent; color: #c1c5cc; cursor: pointer; font-size: 12px; opacity: 0; }
|
||||||
|
.iconCollection article:hover .favoriteButton, .favoriteButton.isFavorite { opacity: 1; }
|
||||||
|
.favoriteButton:hover { background: #fff7df; color: #dda632; }
|
||||||
|
.favoriteButton.isFavorite { color: #e3a930; }
|
||||||
|
.quickCopy { position: absolute; right: 6px; bottom: 6px; padding: 3px 5px; border: 0; border-radius: 4px; background: #edf2ff; color: #4b6abe; cursor: pointer; font-size: 6px; opacity: 0; }
|
||||||
|
.iconCollection article:hover .quickCopy { opacity: 1; }
|
||||||
|
.listView { grid-template-columns: 1fr; }
|
||||||
|
.listView article { height: 58px; }
|
||||||
|
.listView .selectIcon { align-items: center; justify-content: flex-start; flex-direction: row; gap: 15px; padding: 7px 45px 7px 13px; text-align: left; }
|
||||||
|
.listView .iconSymbol { width: 42px; min-height: 42px; }
|
||||||
|
.listView .iconLabel { width: auto; }
|
||||||
|
.listView .iconLabel strong { font-size: 9px; }
|
||||||
|
.listView .quickCopy { right: 10px; top: 50%; bottom: auto; transform: translateY(-50%); opacity: 1; }
|
||||||
|
.listView .favoriteButton { right: 54px; top: 50%; transform: translateY(-50%); opacity: 1; }
|
||||||
|
.empty { grid-column: 1/-1; min-height: 360px; display: flex; align-items: center; justify-content: center; flex-direction: column; color: #9ba3af; text-align: center; }
|
||||||
|
.empty > span { width: 52px; height: 52px; display: grid; place-items: center; border-radius: 50%; background: #edf0f5; font-size: 23px; }
|
||||||
|
.empty h3 { margin: 12px 0 4px; color: #596476; font-size: 11px; }
|
||||||
|
.empty p { margin: 0 0 14px; font-size: 8px; }
|
||||||
|
.empty button { height: 30px; padding: 0 11px; border: 1px solid #d9deea; border-radius: 6px; background: #fff; color: #4866b6; cursor: pointer; font-size: 7px; }
|
||||||
|
|
||||||
|
.inspector { position: sticky; top: 62px; height: calc(100vh - 62px); overflow-y: auto; padding: 19px; border-left: 1px solid #e2e5eb; background: #fff; }
|
||||||
|
.inspectorHeader { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
.inspectorHeader span { display: block; margin-bottom: 3px; color: #7288be; font-size: 6px; font-weight: 800; letter-spacing: 1.2px; }
|
||||||
|
.inspectorHeader strong { font-size: 12px; }
|
||||||
|
.inspectorHeader > button { width: 29px; height: 29px; border: 1px solid #e1e4e9; border-radius: 7px; background: #fff; color: #b1b6bf; cursor: pointer; font-size: 14px; }
|
||||||
|
.inspectorHeader > .inspectorFavorite { border-color: #eed9a2; background: #fff9e9; color: #dfa932; }
|
||||||
|
.largePreview { --preview-size: 28px; --preview-color: #27324a; position: relative; height: 150px; display: grid; place-items: center; margin: 17px 0 15px; overflow: hidden; border: 1px solid #e1e4ea; border-radius: 11px; }
|
||||||
|
.largePreview::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(#d9dee7 1px,transparent 1px),linear-gradient(90deg,#d9dee7 1px,transparent 1px); background-size: 16px 16px; }
|
||||||
|
.largePreview > span { position: relative; z-index: 1; color: var(--preview-color); font-family: "Segoe UI Symbol","Arial Unicode MS",sans-serif; font-size: calc(var(--preview-size) * 2.2); line-height: 1; white-space: nowrap; }
|
||||||
|
.largePreview > small { position: absolute; right: 9px; bottom: 8px; z-index: 1; padding: 3px 5px; border-radius: 4px; background: rgba(255,255,255,.75); color: #8d95a3; font-size: 6px; }
|
||||||
|
.preview_light { background: #fff; }
|
||||||
|
.preview_soft { background: #f0f3f8; }
|
||||||
|
.preview_dark { background: #242b3c; }
|
||||||
|
.preview_dark::before { background-image: linear-gradient(#455066 1px,transparent 1px),linear-gradient(90deg,#455066 1px,transparent 1px); }
|
||||||
|
.preview_dark > small { background: rgba(14,18,29,.65); color: #adb5c5; }
|
||||||
|
.iconMeta { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-bottom: 14px; border-bottom: 1px solid #eceef2; }
|
||||||
|
.iconMeta h2 { margin: 0 0 3px; font-size: 15px; }
|
||||||
|
.iconMeta p { margin: 0; color: #999faa; font-size: 7px; }
|
||||||
|
.iconMeta button { height: 29px; padding: 0 9px; border: 1px solid #ced9f2; border-radius: 6px; background: #f5f8ff; color: #4565b8; cursor: pointer; font-size: 7px; }
|
||||||
|
.details { margin: 8px 0 0; }
|
||||||
|
.details > div { display: flex; justify-content: space-between; gap: 15px; padding: 7px 0; font-size: 7px; }
|
||||||
|
.details dt { color: #9ba1ac; }
|
||||||
|
.details dd { margin: 0; color: #566175; font-family: "Cascadia Code",Consolas,monospace; }
|
||||||
|
.appearance, .codeSection { margin-top: 13px; padding-top: 14px; border-top: 1px solid #eceef2; }
|
||||||
|
.appearance h3, .codeSection h3 { margin: 0 0 12px; font-size: 9px; }
|
||||||
|
.appearance > label { display: grid; grid-template-columns: 31px 1fr 30px; align-items: center; gap: 8px; color: #7f8795; font-size: 7px; }
|
||||||
|
.appearance > label input { width: 100%; accent-color: var(--blue); }
|
||||||
|
.appearance > label b { font-size: 7px; }
|
||||||
|
.colorRow { display: flex; align-items: center; gap: 6px; margin-top: 12px; }
|
||||||
|
.colorRow > span { width: 31px; color: #7f8795; font-size: 7px; }
|
||||||
|
.colorRow button, .colorRow input { width: 19px; height: 19px; padding: 0; border: 2px solid #fff; border-radius: 50%; cursor: pointer; box-shadow: 0 0 0 1px #d7dbe3; }
|
||||||
|
.colorRow .colorActive { box-shadow: 0 0 0 2px #6f89cf; }
|
||||||
|
.colorRow input { overflow: hidden; }
|
||||||
|
.backgroundSwitch { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; margin-top: 12px; padding: 3px; border-radius: 6px; background: #f1f2f5; }
|
||||||
|
.backgroundSwitch button { height: 25px; border: 0; border-radius: 4px; background: transparent; color: #8f96a2; cursor: pointer; font-size: 7px; }
|
||||||
|
.backgroundSwitch .backgroundActive { background: #fff; color: #405fae; box-shadow: 0 2px 6px rgba(34,39,55,.08); }
|
||||||
|
.codeTabs { display: flex; margin-bottom: 7px; border-bottom: 1px solid #e6e8ed; }
|
||||||
|
.codeTabs button { padding: 6px 8px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: #9299a5; cursor: pointer; font-size: 6px; }
|
||||||
|
.codeTabs .codeTabActive { border-bottom-color: var(--blue); color: #3d5fbc; font-weight: 700; }
|
||||||
|
.codeSection pre { min-height: 71px; overflow-x: auto; margin: 0; padding: 10px; border-radius: 7px; background: #252a39; color: #d8deec; font-family: "Cascadia Code",Consolas,monospace; font-size: 7px; line-height: 1.65; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.copyCode { width: 100%; height: 31px; margin-top: 7px; border: 0; border-radius: 6px; background: #3e66d2; color: #fff; cursor: pointer; font-size: 7px; }
|
||||||
|
.compatibility { display: grid; grid-template-columns: 23px 1fr; gap: 2px 8px; margin: 14px 0 0; padding: 10px; border-radius: 7px; background: #edf8f3; }
|
||||||
|
.compatibility > span { grid-row: 1/3; width: 23px; height: 23px; display: grid; place-items: center; border-radius: 50%; background: #d9f1e5; color: #28845a; font-size: 8px; }
|
||||||
|
.compatibility strong { color: #36765b; font-size: 7px; }
|
||||||
|
.compatibility small { color: #759487; font-size: 6px; line-height: 1.5; }
|
||||||
|
.toast { position: fixed; left: 50%; bottom: 24px; z-index: 70; padding: 10px 16px; border-radius: 20px; background: rgba(30,36,51,.96); color: #fff; box-shadow: 0 12px 30px rgba(22,27,40,.23); font-size: 8px; animation: toastIn .2s ease both; }
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.workspace { grid-template-columns: 180px minmax(0,1fr) 280px; }
|
||||||
|
.iconCollection { grid-template-columns: repeat(4,minmax(0,1fr)); }
|
||||||
|
.main { padding: 20px 18px 60px; }
|
||||||
|
.heroSamples span:nth-last-child(-n+2) { display: none; }
|
||||||
|
.sizeControl { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.topbar nav { display: none; }
|
||||||
|
.workspace { grid-template-columns: 165px minmax(0,1fr); }
|
||||||
|
.inspector { grid-column: 2; position: relative; top: auto; height: auto; border-top: 1px solid #e2e5eb; border-left: 0; }
|
||||||
|
.sidebar { grid-row: 1/3; }
|
||||||
|
.inspector { display: grid; grid-template-columns: 210px 1fr; gap: 0 18px; }
|
||||||
|
.inspectorHeader { grid-column: 1/-1; }
|
||||||
|
.largePreview { grid-row: 2/7; }
|
||||||
|
.appearance, .codeSection { margin-top: 10px; }
|
||||||
|
.compatibility { grid-column: 2; }
|
||||||
|
.iconCollection { grid-template-columns: repeat(3,minmax(0,1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.topbar { padding: 0 12px; }
|
||||||
|
.headerActions button { display: none; }
|
||||||
|
.workspace { display: block; }
|
||||||
|
.sidebar { position: relative; top: auto; width: 100%; height: auto; border-right: 0; border-bottom: 1px solid #e3e6eb; }
|
||||||
|
.sidebarTitle { padding: 18px 15px 8px; }
|
||||||
|
.categories { display: flex; gap: 4px; overflow-x: auto; padding: 8px 12px 13px; }
|
||||||
|
.categories button { width: auto; flex: 0 0 auto; padding: 0 11px; }
|
||||||
|
.categories button > span, .categories button > b { display: none; }
|
||||||
|
.sidebarHelp { display: none; }
|
||||||
|
.main { padding: 13px 12px 45px; }
|
||||||
|
.hero { min-height: 132px; padding: 22px; }
|
||||||
|
.hero h1 { max-width: 260px; font-size: 18px; }
|
||||||
|
.hero div > span { display: block; max-width: 270px; line-height: 1.6; }
|
||||||
|
.heroSamples { display: none; }
|
||||||
|
.toolbar { gap: 8px; }
|
||||||
|
.search kbd { display: none; }
|
||||||
|
.iconCollection { grid-template-columns: repeat(3,minmax(0,1fr)); }
|
||||||
|
.iconCollection article { height: 112px; }
|
||||||
|
.favoriteButton, .quickCopy { opacity: 1; }
|
||||||
|
.quickCopy { display: none; }
|
||||||
|
.inspector { display: block; padding: 18px 15px 35px; }
|
||||||
|
.largePreview { height: 170px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 390px) {
|
||||||
|
.iconCollection { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||||
|
}
|
||||||
173
app/icons/page.tsx
Normal file
173
app/icons/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CSSProperties, useEffect, useMemo, useState } from "react";
|
||||||
|
import iconData from "../data/web-icons.json";
|
||||||
|
import styles from "./IconLibrary.module.css";
|
||||||
|
|
||||||
|
type IconItem = (typeof iconData.icons)[number];
|
||||||
|
type CodeMode = "character" | "html" | "react" | "css";
|
||||||
|
type PreviewBackground = "light" | "soft" | "dark";
|
||||||
|
|
||||||
|
export default function IconLibraryPage() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [category, setCategory] = useState("all");
|
||||||
|
const [selectedId, setSelectedId] = useState("home");
|
||||||
|
const [favorites, setFavorites] = useState<string[]>([]);
|
||||||
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
|
const [size, setSize] = useState(28);
|
||||||
|
const [color, setColor] = useState("#27324a");
|
||||||
|
const [background, setBackground] = useState<PreviewBackground>("soft");
|
||||||
|
const [codeMode, setCodeMode] = useState<CodeMode>("html");
|
||||||
|
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage.getItem("orbit-icon-favorites");
|
||||||
|
if (saved) setFavorites(JSON.parse(saved));
|
||||||
|
} catch { /* use an empty favorite list */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem("orbit-icon-favorites", JSON.stringify(favorites));
|
||||||
|
}, [favorites]);
|
||||||
|
|
||||||
|
const selected = iconData.icons.find((icon) => icon.id === selectedId) ?? iconData.icons[0];
|
||||||
|
const filteredIcons = useMemo(() => iconData.icons.filter((icon) => {
|
||||||
|
const text = `${icon.name}${icon.cn}${icon.id}${icon.keywords.join("")}`.toLowerCase();
|
||||||
|
const matchesQuery = text.includes(query.toLowerCase());
|
||||||
|
const matchesCategory = category === "all" || icon.category === category;
|
||||||
|
const matchesFavorite = !onlyFavorites || favorites.includes(icon.id);
|
||||||
|
return matchesQuery && matchesCategory && matchesFavorite;
|
||||||
|
}), [query, category, onlyFavorites, favorites]);
|
||||||
|
|
||||||
|
const categoryName = iconData.categories.find((item) => item.id === selected.category)?.name ?? "通用";
|
||||||
|
const code = getIconCode(selected, codeMode, size, color);
|
||||||
|
const previewStyle = { "--preview-size": `${size}px`, "--preview-color": color } as CSSProperties;
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async (value: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
} catch {
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
input.value = value;
|
||||||
|
input.style.position = "fixed";
|
||||||
|
input.style.opacity = "0";
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
input.remove();
|
||||||
|
}
|
||||||
|
notify(`${label}已复制`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavorite = (id: string) => setFavorites((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
|
||||||
|
|
||||||
|
const exportFavorites = () => {
|
||||||
|
const items = iconData.icons.filter((icon) => favorites.includes(icon.id));
|
||||||
|
const blob = new Blob([JSON.stringify(items, null, 2)], { type: "application/json;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "web-icons-favorites.json";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
notify("收藏图标已导出");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.topbar}>
|
||||||
|
<Link href="/" className={styles.brand}><span>G</span><strong>GlyphKit</strong><small>WEB ICON LIBRARY</small></Link>
|
||||||
|
<nav><Link href="/simple-list">列表模板</Link><Link href="/repeatable-form">可重复表单</Link><Link className={styles.activeNav} href="/icons">图标库</Link><Link href="/markdown">文档编辑</Link></nav>
|
||||||
|
<div className={styles.headerActions}><button onClick={() => setOnlyFavorites((value) => !value)} className={onlyFavorites ? styles.favoriteFilterActive : ""}>☆ 收藏 {favorites.length}</button><button onClick={exportFavorites} disabled={favorites.length === 0}>导出收藏</button><span>林</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.workspace}>
|
||||||
|
<aside className={styles.sidebar}>
|
||||||
|
<div className={styles.sidebarTitle}><span>ICON SET</span><strong>网页图标</strong><small>{iconData.icons.length} 个原生符号</small></div>
|
||||||
|
<nav className={styles.categories} aria-label="图标分类">
|
||||||
|
{iconData.categories.map((item) => {
|
||||||
|
const count = item.id === "all" ? iconData.icons.length : iconData.icons.filter((icon) => icon.category === item.id).length;
|
||||||
|
return <button key={item.id} className={category === item.id ? styles.categoryActive : ""} onClick={() => { setCategory(item.id); setOnlyFavorites(false); }}><span>{categorySymbol(item.id)}</span>{item.name}<b>{count}</b></button>;
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className={styles.sidebarHelp}><span>i</span><p><strong>零依赖图标</strong><small>复制后可直接用于 HTML、CSS 和 React 页面。</small></p></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<div><p>SYMBOL PICKER</p><h1>为界面选择一个合适的图标</h1><span>搜索、预览并复制。全部图标使用系统字体,不需要安装额外资源。</span></div>
|
||||||
|
<div className={styles.heroSamples}>{["⌂","✓","⌕","✎","▦"].map((symbol,index) => <span key={symbol} style={{transform:`translateY(${index % 2 ? 7 : -2}px)`}}>{symbol}</span>)}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索图标名称、功能或关键词,例如:编辑、upload" />{query && <button onClick={() => setQuery("")}>×</button>}<kbd>⌘ K</kbd></label>
|
||||||
|
<div className={styles.sizeControl}><span>预览大小</span><input type="range" min="18" max="42" value={size} onChange={(event) => setSize(Number(event.target.value))} /><b>{size}px</b></div>
|
||||||
|
<div className={styles.viewSwitch}><button className={view === "grid" ? styles.viewActive : ""} onClick={() => setView("grid")} aria-label="网格视图">▦</button><button className={view === "list" ? styles.viewActive : ""} onClick={() => setView("list")} aria-label="列表视图">☷</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.resultHeading}><div><strong>{onlyFavorites ? "我的收藏" : iconData.categories.find((item) => item.id === category)?.name}</strong><span>{filteredIcons.length} 个结果</span></div>{(query || onlyFavorites || category !== "all") && <button onClick={() => { setQuery(""); setCategory("all"); setOnlyFavorites(false); }}>清除筛选</button>}</div>
|
||||||
|
|
||||||
|
<div className={`${styles.iconCollection} ${view === "list" ? styles.listView : ""}`} style={previewStyle}>
|
||||||
|
{filteredIcons.map((icon) => {
|
||||||
|
const favorite = favorites.includes(icon.id);
|
||||||
|
return <article key={icon.id} className={selected.id === icon.id ? styles.selectedCard : ""}>
|
||||||
|
<button className={styles.selectIcon} onClick={() => setSelectedId(icon.id)}>
|
||||||
|
<span className={styles.iconSymbol}>{icon.symbol}</span>
|
||||||
|
<span className={styles.iconLabel}><strong>{icon.cn}</strong><small>{icon.name}</small></span>
|
||||||
|
</button>
|
||||||
|
<button className={`${styles.favoriteButton} ${favorite ? styles.isFavorite : ""}`} onClick={() => toggleFavorite(icon.id)} aria-label={favorite ? `取消收藏 ${icon.cn}` : `收藏 ${icon.cn}`}>{favorite ? "★" : "☆"}</button>
|
||||||
|
<button className={styles.quickCopy} onClick={() => copy(icon.symbol, "图标字符")} aria-label={`复制 ${icon.cn}`}>复制</button>
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
{filteredIcons.length === 0 && <div className={styles.empty}><span>⌕</span><h3>没有找到匹配图标</h3><p>尝试搜索“首页”“编辑”“文件”或英文关键词。</p><button onClick={() => { setQuery(""); setCategory("all"); setOnlyFavorites(false); }}>查看全部图标</button></div>}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside className={styles.inspector}>
|
||||||
|
<div className={styles.inspectorHeader}><div><span>SELECTED ICON</span><strong>图标详情</strong></div><button className={favorites.includes(selected.id) ? styles.inspectorFavorite : ""} onClick={() => toggleFavorite(selected.id)}>{favorites.includes(selected.id) ? "★" : "☆"}</button></div>
|
||||||
|
<div className={`${styles.largePreview} ${styles[`preview_${background}`]}`} style={previewStyle}><span>{selected.symbol}</span><small>{size}px</small></div>
|
||||||
|
<div className={styles.iconMeta}><div><h2>{selected.cn}</h2><p>{selected.name}</p></div><button onClick={() => copy(selected.symbol, "图标字符")}>复制字符</button></div>
|
||||||
|
<dl className={styles.details}><div><dt>图标 ID</dt><dd>{selected.id}</dd></div><div><dt>分类</dt><dd>{categoryName}</dd></div><div><dt>Unicode</dt><dd>U+{unicodeValue(selected.symbol)}</dd></div></dl>
|
||||||
|
|
||||||
|
<section className={styles.appearance}><h3>预览样式</h3><label><span>大小</span><input type="range" min="14" max="64" value={size} onChange={(event) => setSize(Number(event.target.value))} /><b>{size}px</b></label><div className={styles.colorRow}><span>颜色</span>{["#27324a","#3d6fe8","#20a26a","#e45252","#8a5bd1"].map((value) => <button key={value} className={color === value ? styles.colorActive : ""} style={{background:value}} onClick={() => setColor(value)} aria-label={`颜色 ${value}`} />)}<input type="color" value={color} onChange={(event) => setColor(event.target.value)} aria-label="自定义颜色" /></div><div className={styles.backgroundSwitch}>{(["light","soft","dark"] as PreviewBackground[]).map((value) => <button key={value} className={background === value ? styles.backgroundActive : ""} onClick={() => setBackground(value)}>{value === "light" ? "白色" : value === "soft" ? "浅色" : "深色"}</button>)}</div></section>
|
||||||
|
|
||||||
|
<section className={styles.codeSection}>
|
||||||
|
<h3>使用代码</h3>
|
||||||
|
<div className={styles.codeTabs}>{(["character","html","react","css"] as CodeMode[]).map((mode) => <button key={mode} className={codeMode === mode ? styles.codeTabActive : ""} onClick={() => setCodeMode(mode)}>{mode === "character" ? "字符" : mode.toUpperCase()}</button>)}</div>
|
||||||
|
<pre><code>{code}</code></pre>
|
||||||
|
<button className={styles.copyCode} onClick={() => copy(code, `${codeMode.toUpperCase()} 代码`)}>▣ 复制代码</button>
|
||||||
|
</section>
|
||||||
|
<p className={styles.compatibility}><span>✓</span><strong>浏览器原生支持</strong><small>建议使用 Segoe UI Symbol、Arial Unicode MS 或系统无衬线字体。</small></p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function categorySymbol(id: string) {
|
||||||
|
return ({ all: "✣", navigation: "⌖", actions: "✎", status: "◉", media: "▶", files: "▤", business: "▦", communication: "✉", identity: "⚥", biology: "⚛", breeding: "♂♀", pets: "🐾︎" } as Record<string,string>)[id] ?? "◇";
|
||||||
|
}
|
||||||
|
|
||||||
|
function unicodeValue(symbol: string) {
|
||||||
|
return Array.from(symbol).map((char) => char.codePointAt(0)?.toString(16).toUpperCase().padStart(4, "0")).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssEscaped(symbol: string) {
|
||||||
|
return Array.from(symbol).map((char) => `\\${char.codePointAt(0)?.toString(16).toUpperCase()} `).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconCode(icon: IconItem, mode: CodeMode, size: number, color: string) {
|
||||||
|
if (mode === "character") return icon.symbol;
|
||||||
|
if (mode === "html") return `<span class="web-icon" aria-hidden="true">${icon.symbol}</span>`;
|
||||||
|
if (mode === "react") return `<span className="web-icon" aria-hidden>${icon.symbol}</span>`;
|
||||||
|
return `.web-icon::before {\n content: "${cssEscaped(icon.symbol)}";\n color: ${color};\n font: ${size}px/1 "Segoe UI Symbol", sans-serif;\n}`;
|
||||||
|
}
|
||||||
1
app/image-host/ImageHost.module.css
Normal file
1
app/image-host/ImageHost.module.css
Normal file
File diff suppressed because one or more lines are too long
68
app/image-host/ImageHostCard.module.css
Normal file
68
app/image-host/ImageHostCard.module.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
.card {
|
||||||
|
overflow: visible;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardImage {
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(180deg, rgba(73, 75, 79, .42), rgba(39, 40, 43, .82));
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(3px);
|
||||||
|
transition: opacity .2s ease, transform .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cardBody,
|
||||||
|
.card:focus-visible .cardBody {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody > strong {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
text-shadow: 0 2px 5px rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody > small {
|
||||||
|
min-height: 0;
|
||||||
|
color: rgba(255, 255, 255, .77);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.albumBadge {
|
||||||
|
position: static;
|
||||||
|
width: fit-content;
|
||||||
|
margin-bottom: auto;
|
||||||
|
background: rgba(43, 44, 47, .72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter {
|
||||||
|
border-top-color: rgba(255, 255, 255, .2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter em {
|
||||||
|
color: rgba(255, 255, 255, .72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter b {
|
||||||
|
color: #ffd0c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.cardBody {
|
||||||
|
opacity: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
277
app/image-host/page.tsx
Normal file
277
app/image-host/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import galleryData from "../data/image-host.json";
|
||||||
|
import styles from "./ImageHost.module.css";
|
||||||
|
import cardStyles from "./ImageHostCard.module.css";
|
||||||
|
|
||||||
|
type ImageItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
src: string;
|
||||||
|
category: string;
|
||||||
|
ratio: string;
|
||||||
|
position: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
size: string;
|
||||||
|
format: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
views: number;
|
||||||
|
tags: string[];
|
||||||
|
local?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlbumItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
coverId: string;
|
||||||
|
imageIds: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataShape = {
|
||||||
|
categories: string[];
|
||||||
|
images: ImageItem[];
|
||||||
|
albums: AlbumItem[];
|
||||||
|
feed: { kind: "image" | "album"; id: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GalleryItem =
|
||||||
|
| { key: string; kind: "image"; image: ImageItem; albumId?: string }
|
||||||
|
| { key: string; kind: "album"; album: AlbumItem; cover: ImageItem };
|
||||||
|
|
||||||
|
type ViewState =
|
||||||
|
| { type: "gallery" }
|
||||||
|
| { type: "album"; id: string }
|
||||||
|
| { type: "image"; id: string; albumId?: string };
|
||||||
|
|
||||||
|
const data = galleryData as DataShape;
|
||||||
|
|
||||||
|
export default function ImageHostPage() {
|
||||||
|
const uploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const localUrlsRef = useRef<string[]>([]);
|
||||||
|
const [view, setView] = useState<ViewState>({ type: "gallery" });
|
||||||
|
const [category, setCategory] = useState("全部");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [uploadedImages, setUploadedImages] = useState<ImageItem[]>([]);
|
||||||
|
const [quickPreview, setQuickPreview] = useState<GalleryItem | null>(null);
|
||||||
|
const [armingKey, setArmingKey] = useState<string | null>(null);
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
|
||||||
|
const allImages = useMemo(() => [...uploadedImages, ...data.images], [uploadedImages]);
|
||||||
|
const imageMap = useMemo(() => new Map(allImages.map((image) => [image.id, image])), [allImages]);
|
||||||
|
|
||||||
|
const feedItems = useMemo(() => {
|
||||||
|
const uploaded: GalleryItem[] = uploadedImages.map((image) => ({ key: `image-${image.id}`, kind: "image", image }));
|
||||||
|
const bundled = data.feed.flatMap<GalleryItem>((entry) => {
|
||||||
|
if (entry.kind === "image") {
|
||||||
|
const image = imageMap.get(entry.id);
|
||||||
|
return image ? [{ key: `image-${image.id}`, kind: "image", image }] : [];
|
||||||
|
}
|
||||||
|
const album = data.albums.find((item) => item.id === entry.id);
|
||||||
|
const cover = album ? imageMap.get(album.coverId) : undefined;
|
||||||
|
return album && cover ? [{ key: `album-${album.id}`, kind: "album", album, cover }] : [];
|
||||||
|
});
|
||||||
|
return [...uploaded, ...bundled];
|
||||||
|
}, [imageMap, uploadedImages]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => feedItems.filter((item) => {
|
||||||
|
const itemCategory = item.kind === "image" ? item.image.category : item.album.category;
|
||||||
|
const searchable = item.kind === "image"
|
||||||
|
? `${item.image.title} ${item.image.tags.join(" ")} ${item.image.category}`
|
||||||
|
: `${item.album.title} ${item.album.description} ${item.album.tags.join(" ")} ${item.album.category}`;
|
||||||
|
return (category === "全部" || itemCategory === category) && searchable.toLowerCase().includes(query.trim().toLowerCase());
|
||||||
|
}), [feedItems, category, query]);
|
||||||
|
|
||||||
|
const currentAlbum = view.type === "album" ? data.albums.find((album) => album.id === view.id) ?? null : null;
|
||||||
|
const albumImages = currentAlbum ? currentAlbum.imageIds.map((id) => imageMap.get(id)).filter((image): image is ImageItem => Boolean(image)) : [];
|
||||||
|
const currentImage = view.type === "image" ? imageMap.get(view.id) ?? null : null;
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
||||||
|
localUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 1900);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPreviewTimer = () => {
|
||||||
|
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
||||||
|
previewTimerRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const armPreview = (item: GalleryItem) => {
|
||||||
|
clearPreviewTimer();
|
||||||
|
setQuickPreview(null);
|
||||||
|
setArmingKey(item.key);
|
||||||
|
previewTimerRef.current = window.setTimeout(() => {
|
||||||
|
setQuickPreview(item);
|
||||||
|
setArmingKey(null);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disarmPreview = () => {
|
||||||
|
clearPreviewTimer();
|
||||||
|
setArmingKey(null);
|
||||||
|
setQuickPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeCategory = (value: string) => {
|
||||||
|
setCategory(value);
|
||||||
|
setView({ type: "gallery" });
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openItem = (item: GalleryItem) => {
|
||||||
|
disarmPreview();
|
||||||
|
setView(item.kind === "album" ? { type: "album", id: item.album.id } : { type: "image", id: item.image.id, albumId: item.albumId });
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (view.type === "image" && view.albumId) setView({ type: "album", id: view.albumId });
|
||||||
|
else setView({ type: "gallery" });
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadImages = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []).filter((file) => file.type.startsWith("image/"));
|
||||||
|
if (!files.length) return;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const additions = files.map<ImageItem>((file, index) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
localUrlsRef.current.push(url);
|
||||||
|
return {
|
||||||
|
id: `local-${Date.now()}-${index}`,
|
||||||
|
title: file.name.replace(/\.[^.]+$/, ""),
|
||||||
|
src: url,
|
||||||
|
category: "动漫",
|
||||||
|
ratio: index % 2 ? "4 / 5" : "3 / 2",
|
||||||
|
position: "center",
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
size: formatBytes(file.size),
|
||||||
|
format: file.type.split("/")[1]?.toUpperCase() || "IMAGE",
|
||||||
|
uploadedAt: today,
|
||||||
|
views: 0,
|
||||||
|
tags: ["本地预览", "刚刚上传"],
|
||||||
|
local: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setUploadedImages((current) => [...additions, ...current]);
|
||||||
|
setView({ type: "gallery" });
|
||||||
|
setCategory("全部");
|
||||||
|
event.target.value = "";
|
||||||
|
notify(`已添加 ${additions.length} 张本地图片`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyImageUrl = async (image: ImageItem) => {
|
||||||
|
const value = image.local ? image.src : `${window.location.origin}${image.src}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
notify("图片地址已复制");
|
||||||
|
} catch {
|
||||||
|
notify("当前浏览器无法复制地址");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<button className={styles.brand} onClick={() => setView({ type: "gallery" })} aria-label="返回图床首页"><span>◫</span><div><strong>LumaBox</strong><small>IMAGE HOST</small></div></button>
|
||||||
|
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} onFocus={() => setView({ type: "gallery" })} placeholder="搜索图片、标签或图集" />{query && <button onClick={() => setQuery("")} aria-label="清空搜索">×</button>}</label>
|
||||||
|
<div className={styles.headerActions}><Link href="/">模板首页</Link><button className={styles.uploadButton} onClick={() => uploadRef.current?.click()}>+ 上传图片</button><input ref={uploadRef} type="file" accept="image/*" multiple onChange={uploadImages} /></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className={styles.categoryBar} aria-label="图片分类">
|
||||||
|
<div>{data.categories.map((item) => <button key={item} className={category === item && view.type === "gallery" ? styles.categoryActive : ""} onClick={() => changeCategory(item)}>{item}</button>)}</div>
|
||||||
|
<p><span>鼠标停留 3 秒</span>可快速大图预览</p>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{view.type === "gallery" && <main className={styles.galleryPage}>
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<div><p>YOUR VISUAL LIBRARY</p><h1>把喜欢的画面,收进一座安静的图床</h1><span>响应式卡片流 · 本地上传演示 · 图集连续浏览</span></div>
|
||||||
|
<div className={styles.summary}><span><b>{feedItems.length}</b><small>展示项目</small></span><span><b>{data.albums.length}</b><small>公开图集</small></span><span><b>3s</b><small>悬停预览</small></span></div>
|
||||||
|
</section>
|
||||||
|
<div className={styles.resultLine}><div><strong>{category === "全部" ? "最近上传" : category}</strong><span>{filteredItems.length} 个结果</span></div>{(query || category !== "全部") && <button onClick={() => { setQuery(""); setCategory("全部"); }}>清除筛选</button>}</div>
|
||||||
|
{filteredItems.length > 0 ? <GalleryGrid items={filteredItems} armingKey={armingKey} onArm={armPreview} onDisarm={disarmPreview} onOpen={openItem} /> : <div className={styles.empty}><span>◇</span><h2>没有找到匹配内容</h2><p>换一个关键词或查看全部分类。</p><button onClick={() => { setQuery(""); setCategory("全部"); }}>查看全部图片</button></div>}
|
||||||
|
</main>}
|
||||||
|
|
||||||
|
{view.type === "album" && currentAlbum && <main className={styles.albumPage}>
|
||||||
|
<button className={styles.backButton} onClick={goBack}>← 返回图床</button>
|
||||||
|
<section className={styles.albumHero}>
|
||||||
|
<div className={styles.albumCover}>{albumImages.slice(0, 3).map((image, index) => <img key={image.id} src={image.src} alt="" style={{ objectPosition: image.position, zIndex: 3 - index }} />)}</div>
|
||||||
|
<div className={styles.albumInfo}><p>ALBUM COLLECTION</p><h1>{currentAlbum.title}</h1><span>{currentAlbum.description}</span><div>{currentAlbum.tags.map((tag) => <b key={tag}>#{tag}</b>)}</div><small>{albumImages.length} 张图片 · 更新于 {currentAlbum.updatedAt}</small></div>
|
||||||
|
</section>
|
||||||
|
<div className={styles.resultLine}><div><strong>图集内容</strong><span>与图床相同的卡片浏览方式</span></div></div>
|
||||||
|
<GalleryGrid items={albumImages.map((image) => ({ key: `album-image-${currentAlbum.id}-${image.id}`, kind: "image", image, albumId: currentAlbum.id }))} armingKey={armingKey} onArm={armPreview} onDisarm={disarmPreview} onOpen={openItem} />
|
||||||
|
</main>}
|
||||||
|
|
||||||
|
{view.type === "image" && currentImage && <main className={styles.detailPage}>
|
||||||
|
<button className={styles.backButton} onClick={goBack}>← {view.albumId ? "返回图集" : "返回图床"}</button>
|
||||||
|
<div className={styles.detailLayout}>
|
||||||
|
<figure className={styles.detailVisual}><img src={currentImage.src} alt={currentImage.title} /><figcaption>原图预览 · {currentImage.format}</figcaption></figure>
|
||||||
|
<aside className={styles.detailInfo}>
|
||||||
|
<p>IMAGE DETAIL</p><h1>{currentImage.title}</h1>
|
||||||
|
<div className={styles.detailTags}>{currentImage.tags.map((tag) => <span key={tag}>#{tag}</span>)}</div>
|
||||||
|
<dl><div><dt>图片格式</dt><dd>{currentImage.format}</dd></div><div><dt>原始尺寸</dt><dd>{currentImage.width ? `${currentImage.width} × ${currentImage.height}` : "本地预览"}</dd></div><div><dt>文件大小</dt><dd>{currentImage.size}</dd></div><div><dt>上传时间</dt><dd>{currentImage.uploadedAt}</dd></div><div><dt>浏览次数</dt><dd>{currentImage.views.toLocaleString()}</dd></div><div><dt>所属分类</dt><dd>{currentImage.category}</dd></div></dl>
|
||||||
|
<button className={styles.copyButton} onClick={() => copyImageUrl(currentImage)}>复制图片地址</button>
|
||||||
|
<a className={styles.rawButton} href={currentImage.src} target="_blank" rel="noreferrer">在新窗口查看原图 ↗</a>
|
||||||
|
<p className={styles.detailHint}>此模板使用本地 JSON 和浏览器文件预览,不需要后端接口。</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>}
|
||||||
|
|
||||||
|
{quickPreview && <div className={styles.quickBackdrop} aria-hidden="true"><figure className={styles.quickPreview}><img src={quickPreview.kind === "image" ? quickPreview.image.src : quickPreview.cover.src} alt="" style={{ objectPosition: quickPreview.kind === "image" ? quickPreview.image.position : quickPreview.cover.position }} /><figcaption><span>{quickPreview.kind === "album" ? `图集 · ${quickPreview.album.imageIds.length} 张` : "图片快速预览"}</span><strong>{quickPreview.kind === "image" ? quickPreview.image.title : quickPreview.album.title}</strong><small>点击当前卡片进入详情</small></figcaption></figure></div>}
|
||||||
|
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GalleryGrid({ items, armingKey, onArm, onDisarm, onOpen }: {
|
||||||
|
items: GalleryItem[];
|
||||||
|
armingKey: string | null;
|
||||||
|
onArm: (item: GalleryItem) => void;
|
||||||
|
onDisarm: () => void;
|
||||||
|
onOpen: (item: GalleryItem) => void;
|
||||||
|
}) {
|
||||||
|
return <section className={styles.masonry} aria-label="图片卡片列表">{items.map((item) => {
|
||||||
|
const image = item.kind === "image" ? item.image : item.cover;
|
||||||
|
const title = item.kind === "image" ? item.image.title : item.album.title;
|
||||||
|
const tags = item.kind === "image" ? item.image.tags : item.album.tags;
|
||||||
|
return <button
|
||||||
|
key={item.key}
|
||||||
|
className={`${styles.card} ${cardStyles.card} ${item.kind === "album" ? styles.albumCard : ""}`}
|
||||||
|
onPointerEnter={() => onArm(item)}
|
||||||
|
onPointerMove={() => onArm(item)}
|
||||||
|
onPointerLeave={onDisarm}
|
||||||
|
onFocus={() => onArm(item)}
|
||||||
|
onBlur={onDisarm}
|
||||||
|
onClick={() => onOpen(item)}
|
||||||
|
>
|
||||||
|
<span className={`${styles.cardImage} ${cardStyles.cardImage}`} style={{ aspectRatio: image.ratio }}>
|
||||||
|
<img src={image.src} alt={title} style={{ objectPosition: image.position }} />
|
||||||
|
<span className={`${styles.cardBody} ${cardStyles.cardBody}`}>
|
||||||
|
{item.kind === "album" && <b className={`${styles.albumBadge} ${cardStyles.albumBadge}`}>▣ {item.album.imageIds.length} 张图集</b>}
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<small>{item.kind === "album" ? item.album.description : `${image.width || "本地"}${image.width ? ` × ${image.height}` : ""} · ${image.format}`}</small>
|
||||||
|
<span className={`${styles.cardFooter} ${cardStyles.cardFooter}`}><em>{tags.slice(0, 2).map((tag) => `#${tag}`).join(" ")}</em><b>{item.kind === "album" ? "进入图集" : `${image.views.toLocaleString()} 浏览`} →</b></span>
|
||||||
|
</span>
|
||||||
|
{armingKey === item.key && <i className={styles.previewProgress} />}
|
||||||
|
</span>
|
||||||
|
</button>;
|
||||||
|
})}</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
@@ -9,27 +9,23 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
const origin = `${protocol}://${host}`;
|
const origin = `${protocol}://${host}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "OrbitFlow · React 后台模板",
|
title: "WBBR · AI 前端样式模板工程",
|
||||||
description: "包含组件样式、内容列表、流程泳道和定时任务的纯前端 React 后台模板。",
|
description: "用于在项目内为 AI 定义布局、组件、交互和响应式规则的 React 前端样式模板工程。",
|
||||||
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
|
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "OrbitFlow · React 后台模板",
|
title: "WBBR · AI 前端样式模板工程",
|
||||||
description: "纯前端、JSON 数据驱动的现代后台模板。",
|
description: "通过可运行页面为项目内 AI 提供统一的前端视觉与交互基准。",
|
||||||
images: [{ url: new URL("/og.png", origin).toString(), width: 1200, height: 630, alt: "OrbitFlow 后台模板" }],
|
images: [{ url: new URL("/og.png", origin).toString(), width: 1200, height: 630, alt: "WBBR AI 前端样式模板工程" }],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "OrbitFlow · React 后台模板",
|
title: "WBBR · AI 前端样式模板工程",
|
||||||
description: "组件、泳道、定时任务,一套可直接使用的纯前端后台模板。",
|
description: "后台、内容与可视化页面样式基准,全部使用本地 JSON 演示。",
|
||||||
images: [new URL("/og.png", origin).toString()],
|
images: [new URL("/og.png", origin).toString()],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return <html lang="zh-CN"><body>{children}</body></html>;
|
||||||
<html lang="zh-CN">
|
|
||||||
<body>{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
1
app/markdown/MarkdownEditor.module.css
Normal file
1
app/markdown/MarkdownEditor.module.css
Normal file
File diff suppressed because one or more lines are too long
185
app/markdown/page.tsx
Normal file
185
app/markdown/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Fragment, ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import data from "../data/content-templates.json";
|
||||||
|
import styles from "./MarkdownEditor.module.css";
|
||||||
|
|
||||||
|
type ViewMode = "edit" | "split" | "preview";
|
||||||
|
type DocumentItem = (typeof data.documents)[number];
|
||||||
|
|
||||||
|
export default function MarkdownEditorPage() {
|
||||||
|
const [documents, setDocuments] = useState<DocumentItem[]>(data.documents);
|
||||||
|
const [activeId, setActiveId] = useState(data.documents[0].id);
|
||||||
|
const [contents, setContents] = useState<Record<string, string>>(() => Object.fromEntries(data.documents.map((doc) => [doc.id, doc.content])));
|
||||||
|
const [view, setView] = useState<ViewMode>("split");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [saved, setSaved] = useState(true);
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const cached = window.localStorage.getItem("orbit-markdown-workspace");
|
||||||
|
if (cached) {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
if (parsed.contents) setContents((current) => ({ ...current, ...parsed.contents }));
|
||||||
|
if (Array.isArray(parsed.documents)) setDocuments(parsed.documents);
|
||||||
|
if (typeof parsed.activeId === "string") setActiveId(parsed.activeId);
|
||||||
|
}
|
||||||
|
} catch { /* use JSON defaults */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSaved(false);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
window.localStorage.setItem("orbit-markdown-workspace", JSON.stringify({ documents, contents, activeId }));
|
||||||
|
setSaved(true);
|
||||||
|
}, 500);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [documents, contents, activeId]);
|
||||||
|
|
||||||
|
const activeDocument = documents.find((doc) => doc.id === activeId) ?? documents[0];
|
||||||
|
const content = contents[activeId] ?? "";
|
||||||
|
const filteredDocuments = documents.filter((doc) => doc.title.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
chars: content.replace(/\s/g, "").length,
|
||||||
|
lines: content.split("\n").length,
|
||||||
|
words: content.trim() ? content.trim().split(/\s+/).length : 0,
|
||||||
|
}), [content]);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateContent = (value: string) => setContents((current) => ({ ...current, [activeId]: value }));
|
||||||
|
|
||||||
|
const insert = (before: string, placeholder = "文本", after = "") => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selected = content.slice(start, end) || placeholder;
|
||||||
|
const next = `${content.slice(0, start)}${before}${selected}${after}${content.slice(end)}`;
|
||||||
|
updateContent(next);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDocument = () => {
|
||||||
|
const id = `md-${Date.now()}`;
|
||||||
|
const newDocument: DocumentItem = { id, title: "未命名文档", category: "草稿", updated: "刚刚", content: "# 未命名文档\n\n开始写作……" };
|
||||||
|
setDocuments((current) => [newDocument, ...current]);
|
||||||
|
setContents((current) => ({ ...current, [id]: newDocument.content }));
|
||||||
|
setActiveId(id);
|
||||||
|
notify("已创建新文档");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameDocument = (title: string) => setDocuments((current) => current.map((doc) => doc.id === activeId ? { ...doc, title, updated: "刚刚" } : doc));
|
||||||
|
|
||||||
|
const downloadDocument = () => {
|
||||||
|
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = window.document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${activeDocument?.title || "document"}.md`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
notify("Markdown 文件已导出");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.topbar}>
|
||||||
|
<Link className={styles.brand} href="/"><span>M↓</span><strong>Paper</strong></Link>
|
||||||
|
<nav><Link href="/simple-list">简单列表</Link><Link href="/character">角色详情</Link><Link className={styles.active} href="/markdown">Markdown 编辑器</Link></nav>
|
||||||
|
<div className={styles.saveState}><i className={saved ? styles.saved : ""} />{saved ? "已自动保存" : "正在保存…"}</div>
|
||||||
|
<div className={styles.headerActions}><button onClick={() => notify("协作成员面板为模板占位")}>+ 协作</button><button className={styles.exportButton} onClick={downloadDocument}>导出 .md</button><span>林</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.workspace}>
|
||||||
|
<aside className={styles.sidebar}>
|
||||||
|
<div className={styles.sidebarHeader}><div><strong>我的文档</strong><small>{documents.length} 篇</small></div><button onClick={createDocument}>+</button></div>
|
||||||
|
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索文档" /></label>
|
||||||
|
<div className={styles.documentList}>
|
||||||
|
<p>最近编辑</p>
|
||||||
|
{filteredDocuments.map((doc) => <button key={doc.id} className={activeId === doc.id ? styles.documentActive : ""} onClick={() => setActiveId(doc.id)}><span>{doc.category === "草稿" ? "○" : "M"}</span><div><strong>{doc.title}</strong><small>{doc.category} · {doc.updated}</small></div><i>•••</i></button>)}
|
||||||
|
{filteredDocuments.length === 0 && <div className={styles.noDocument}>没有匹配文档</div>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.sidebarFooter}><Link href="/simple-list">▤ 内容资料库</Link><button onClick={() => notify("设置面板为模板占位")}>⚙ 编辑器设置</button></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className={styles.editorArea}>
|
||||||
|
<div className={styles.documentHeader}>
|
||||||
|
<div><span>{activeDocument?.category || "文档"}</span><input value={activeDocument?.title || ""} onChange={(event) => renameDocument(event.target.value)} aria-label="文档标题" /></div>
|
||||||
|
<div className={styles.viewSwitch}><button className={view === "edit" ? styles.viewActive : ""} onClick={() => setView("edit")}>编辑</button><button className={view === "split" ? styles.viewActive : ""} onClick={() => setView("split")}>分栏</button><button className={view === "preview" ? styles.viewActive : ""} onClick={() => setView("preview")}>预览</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div><button title="一级标题" onClick={() => insert("# ", "标题")}>H1</button><button title="二级标题" onClick={() => insert("## ", "标题")}>H2</button><button title="三级标题" onClick={() => insert("### ", "标题")}>H3</button></div>
|
||||||
|
<i />
|
||||||
|
<div><button title="粗体" onClick={() => insert("**", "粗体文本", "**")}><b>B</b></button><button title="斜体" onClick={() => insert("*", "斜体文本", "*")}><em>I</em></button><button title="删除线" onClick={() => insert("~~", "删除文本", "~~")}><s>S</s></button></div>
|
||||||
|
<i />
|
||||||
|
<div><button title="无序列表" onClick={() => insert("- ", "列表项")}>•≡</button><button title="有序列表" onClick={() => insert("1. ", "列表项")}>1≡</button><button title="引用" onClick={() => insert("> ", "引用内容")}>❞</button><button title="行内代码" onClick={() => insert("`", "代码", "`")}></></button><button title="链接" onClick={() => insert("[", "链接文字", "](https://example.com)")}>⌁</button><button title="分割线" onClick={() => insert("\n---\n", "")}>—</button></div>
|
||||||
|
<span className={styles.toolbarHint}>Markdown · UTF-8</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.panes} ${styles[`view_${view}`]}`}>
|
||||||
|
{view !== "preview" && <section className={styles.editorPane}>
|
||||||
|
<div className={styles.lineNumbers}>{content.split("\n").map((_, index) => <span key={index}>{index + 1}</span>)}</div>
|
||||||
|
<textarea ref={textareaRef} value={content} onChange={(event) => updateContent(event.target.value)} spellCheck={false} aria-label="Markdown 编辑区" />
|
||||||
|
</section>}
|
||||||
|
{view !== "edit" && <section className={styles.previewPane}><MarkdownPreview content={content} /></section>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className={styles.statusbar}><div><span>Ln {textareaRef.current ? content.slice(0, textareaRef.current.selectionStart).split("\n").length : 1}</span><span>{stats.lines} 行</span><span>{stats.chars} 字符</span><span>{stats.words} 词</span></div><div><span>Markdown</span><span>UTF-8</span><button onClick={() => notify("快捷键:Ctrl / Cmd + S 保存")}>⌨ 快捷键</button></div></footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownPreview({ content }: { content: string }) {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
let codeMode = false;
|
||||||
|
const blocks: ReactNode[] = [];
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (line.trim().startsWith("```")) {
|
||||||
|
codeMode = !codeMode;
|
||||||
|
blocks.push(<div className={styles.codeFence} key={index}>{codeMode ? "CODE" : ""}</div>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (codeMode) { blocks.push(<code className={styles.codeLine} key={index}>{line || " "}</code>); return; }
|
||||||
|
if (/^###\s/.test(line)) blocks.push(<h3 key={index}>{renderInline(line.slice(4))}</h3>);
|
||||||
|
else if (/^##\s/.test(line)) blocks.push(<h2 key={index}>{renderInline(line.slice(3))}</h2>);
|
||||||
|
else if (/^#\s/.test(line)) blocks.push(<h1 key={index}>{renderInline(line.slice(2))}</h1>);
|
||||||
|
else if (/^>\s?/.test(line)) blocks.push(<blockquote key={index}>{renderInline(line.replace(/^>\s?/, ""))}</blockquote>);
|
||||||
|
else if (/^---+$/.test(line.trim())) blocks.push(<hr key={index} />);
|
||||||
|
else if (/^-\s/.test(line)) blocks.push(<div className={styles.unordered} key={index}><span>•</span><p>{renderInline(line.slice(2))}</p></div>);
|
||||||
|
else if (/^\d+\.\s/.test(line)) blocks.push(<div className={styles.ordered} key={index}><span>{line.match(/^\d+/)?.[0]}.</span><p>{renderInline(line.replace(/^\d+\.\s/, ""))}</p></div>);
|
||||||
|
else if (!line.trim()) blocks.push(<div className={styles.blank} key={index} />);
|
||||||
|
else blocks.push(<p key={index}>{renderInline(line)}</p>);
|
||||||
|
});
|
||||||
|
return <article className={styles.markdownBody}>{blocks}</article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(text: string): ReactNode {
|
||||||
|
const token = /(\*\*.+?\*\*|~~.+?~~|`.+?`|\*.+?\*|\[[^\]]+\]\([^\)]+\))/g;
|
||||||
|
return text.split(token).map((part, index) => {
|
||||||
|
if (part.startsWith("**") && part.endsWith("**")) return <strong key={index}>{part.slice(2, -2)}</strong>;
|
||||||
|
if (part.startsWith("~~") && part.endsWith("~~")) return <del key={index}>{part.slice(2, -2)}</del>;
|
||||||
|
if (part.startsWith("`") && part.endsWith("`")) return <code key={index}>{part.slice(1, -1)}</code>;
|
||||||
|
if (part.startsWith("*") && part.endsWith("*")) return <em key={index}>{part.slice(1, -1)}</em>;
|
||||||
|
const link = part.match(/^\[([^\]]+)\]\(([^\)]+)\)$/);
|
||||||
|
if (link) {
|
||||||
|
const href = /^(https?:\/\/|#)/.test(link[2]) ? link[2] : "#";
|
||||||
|
return <a key={index} href={href} target={href.startsWith("http") ? "_blank" : undefined} rel="noreferrer">{link[1]}</a>;
|
||||||
|
}
|
||||||
|
return <Fragment key={index}>{part}</Fragment>;
|
||||||
|
});
|
||||||
|
}
|
||||||
24
app/media-player/MediaLyrics.module.css
Normal file
24
app/media-player/MediaLyrics.module.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.musicHero{grid-template-columns:240px minmax(360px,630px) minmax(270px,330px);gap:38px}
|
||||||
|
.lyricsPanel{position:relative;z-index:2;height:310px;display:flex;overflow:hidden;flex-direction:column;border:1px solid rgba(255,255,255,.1);border-radius:13px;background:rgba(8,11,20,.48);box-shadow:0 18px 45px rgba(0,0,0,.24);backdrop-filter:blur(15px)}
|
||||||
|
.lyricsPanel>header{height:52px;display:flex;align-items:center;justify-content:space-between;padding:0 13px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||||
|
.lyricsPanel>header>div:first-child{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.lyricsPanel>header span{color:#8e72f4;font-size:7px;font-weight:800;letter-spacing:1.5px}
|
||||||
|
.lyricsPanel>header strong{font-size:10px}
|
||||||
|
.lyricVolume{display:flex;align-items:center;gap:4px}
|
||||||
|
.lyricVolume button{width:25px;height:25px;border:0;border-radius:5px;background:transparent;color:#aeb4c3;cursor:pointer;font-size:9px}
|
||||||
|
.lyricVolume input{width:58px;height:3px;accent-color:#8b6aff}
|
||||||
|
.lyricsScroll{position:relative;flex:1;overflow-y:auto;padding:92px 12px;scrollbar-width:none;mask-image:linear-gradient(transparent,#000 18%,#000 82%,transparent)}
|
||||||
|
.lyricsScroll::-webkit-scrollbar{display:none}
|
||||||
|
.lyricsScroll button{width:100%;display:flex;align-items:center;flex-direction:column;gap:3px;padding:8px 5px;border:0;background:transparent;color:#626a7d;cursor:pointer;text-align:center;transition:color .22s,transform .22s,opacity .22s;opacity:.58}
|
||||||
|
.lyricsScroll button:hover{color:#c4cada;opacity:.9}
|
||||||
|
.lyricsScroll button strong{font-size:9px;font-weight:600}
|
||||||
|
.lyricsScroll button small{color:#51596b;font-size:6px}
|
||||||
|
.lyricsScroll .lyricActive{color:#fff;opacity:1;transform:scale(1.05)}
|
||||||
|
.lyricsScroll .lyricActive strong{font-size:11px;text-shadow:0 0 15px rgba(139,106,255,.45)}
|
||||||
|
.lyricsScroll .lyricActive small{color:#9c8bd9}
|
||||||
|
.lyricsEmpty{display:grid;flex:1;place-content:center;justify-items:center;padding:20px;color:#626a7c;text-align:center}
|
||||||
|
.lyricsEmpty>span{width:36px;height:36px;display:grid;place-items:center;border:1px solid #343a4c;border-radius:50%;font-size:13px}
|
||||||
|
.lyricsEmpty strong{margin-top:10px;color:#9da4b4;font-size:9px}
|
||||||
|
.lyricsEmpty small{max-width:190px;margin-top:5px;font-size:7px;line-height:1.6}
|
||||||
|
@media(max-width:1050px){.musicHero{grid-template-columns:200px 1fr}.lyricsPanel{grid-column:1/-1;width:min(760px,100%);height:240px;margin:0 auto}.lyricsScroll{padding-top:65px;padding-bottom:65px}}
|
||||||
|
@media(max-width:720px){.musicHero{grid-template-columns:1fr}.lyricsPanel{height:235px}.lyricVolume input{width:48px}}
|
||||||
1
app/media-player/MediaPlayer.module.css
Normal file
1
app/media-player/MediaPlayer.module.css
Normal file
File diff suppressed because one or more lines are too long
326
app/media-player/MediaPlayerExperience.tsx
Normal file
326
app/media-player/MediaPlayerExperience.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChangeEvent, DragEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import mediaData from "../data/media-player.json";
|
||||||
|
import styles from "./MediaPlayer.module.css";
|
||||||
|
import lyricStyles from "./MediaLyrics.module.css";
|
||||||
|
import playlistStyles from "./MediaPlaylist.module.css";
|
||||||
|
|
||||||
|
type Mode = "video" | "music";
|
||||||
|
type PlayMode = "sequence" | "list-loop" | "single-loop" | "shuffle";
|
||||||
|
type VideoItem = (typeof mediaData.videos)[number] & { local?: boolean };
|
||||||
|
type TrackItem = (typeof mediaData.tracks)[number] & { local?: boolean };
|
||||||
|
|
||||||
|
export default function MediaPlayerExperience({ mode }: { mode: Mode }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const videoShellRef = useRef<HTMLDivElement>(null);
|
||||||
|
const videoUploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
const audioUploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
const lyricsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const localUrlsRef = useRef<string[]>([]);
|
||||||
|
const [videos, setVideos] = useState<VideoItem[]>(mediaData.videos);
|
||||||
|
const [currentVideoId, setCurrentVideoId] = useState(mediaData.videos[0].id);
|
||||||
|
const [tracks, setTracks] = useState<TrackItem[]>(mediaData.tracks);
|
||||||
|
const [trackIndex, setTrackIndex] = useState(0);
|
||||||
|
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||||
|
const [musicPlaying, setMusicPlaying] = useState(false);
|
||||||
|
const [videoTime, setVideoTime] = useState(0);
|
||||||
|
const [videoDuration, setVideoDuration] = useState(0);
|
||||||
|
const [musicTime, setMusicTime] = useState(0);
|
||||||
|
const [musicDuration, setMusicDuration] = useState(0);
|
||||||
|
const [volume, setVolume] = useState(0.76);
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [speed, setSpeed] = useState(1);
|
||||||
|
const [musicPlayMode, setMusicPlayMode] = useState<PlayMode>("sequence");
|
||||||
|
const [videoPlayMode, setVideoPlayMode] = useState<PlayMode>("sequence");
|
||||||
|
const [draggedTrackId, setDraggedTrackId] = useState<string | null>(null);
|
||||||
|
const [trackDropId, setTrackDropId] = useState<string | null>(null);
|
||||||
|
const [draggedVideoId, setDraggedVideoId] = useState<string | null>(null);
|
||||||
|
const [videoDropId, setVideoDropId] = useState<string | null>(null);
|
||||||
|
const [videoError, setVideoError] = useState(false);
|
||||||
|
const [audioError, setAudioError] = useState(false);
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
|
||||||
|
const currentVideo = videos.find((video) => video.id === currentVideoId) ?? videos[0];
|
||||||
|
const currentTrack = tracks[trackIndex] ?? tracks[0];
|
||||||
|
const waveform = useMemo(() => Array.from({ length: 68 }, (_, index) => 18 + ((index * 29 + 17) % 70)), []);
|
||||||
|
const activeLyricIndex = useMemo(() => {
|
||||||
|
if (!currentTrack?.lyrics?.length) return -1;
|
||||||
|
let active = 0;
|
||||||
|
currentTrack.lyrics.forEach((line, index) => { if (line.time <= musicTime) active = index; });
|
||||||
|
return active;
|
||||||
|
}, [currentTrack, musicTime]);
|
||||||
|
|
||||||
|
useEffect(() => () => localUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = volume;
|
||||||
|
videoRef.current.muted = muted;
|
||||||
|
}
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = volume;
|
||||||
|
audioRef.current.muted = muted;
|
||||||
|
}
|
||||||
|
}, [volume, muted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMusicTime(0);
|
||||||
|
setMusicDuration(0);
|
||||||
|
setAudioError(false);
|
||||||
|
}, [trackIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = lyricsRef.current;
|
||||||
|
const line = container?.querySelector<HTMLElement>(`[data-lyric-index="${activeLyricIndex}"]`);
|
||||||
|
if (!container || !line) return;
|
||||||
|
container.scrollTo({ top: line.offsetTop - container.clientHeight / 2 + line.clientHeight / 2, behavior: "smooth" });
|
||||||
|
}, [activeLyricIndex]);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToMode = (next: Mode) => {
|
||||||
|
if (next === mode) return;
|
||||||
|
videoRef.current?.pause();
|
||||||
|
audioRef.current?.pause();
|
||||||
|
window.location.href = next === "video" ? "/media-player" : "/music-player";
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleVideo = async () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.paused) {
|
||||||
|
try { await video.play(); } catch { notify("视频尚未加载,请选择本地视频重试"); }
|
||||||
|
} else video.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMusic = async () => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (audio.paused) {
|
||||||
|
try { await audio.play(); } catch { notify("音乐尚未加载,请选择本地音乐重试"); }
|
||||||
|
} else audio.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeVideoTime = (value: number) => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
videoRef.current.currentTime = value;
|
||||||
|
setVideoTime(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeMusicTime = (value: number) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
audioRef.current.currentTime = value;
|
||||||
|
setMusicTime(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeVolume = (value: number) => {
|
||||||
|
setVolume(value);
|
||||||
|
setMuted(value === 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => setMuted((value) => !value);
|
||||||
|
|
||||||
|
const changeSpeed = (value: number) => {
|
||||||
|
setSpeed(value);
|
||||||
|
if (videoRef.current) videoRef.current.playbackRate = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPictureInPicture = async () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !document.pictureInPictureEnabled) return notify("当前浏览器不支持画中画");
|
||||||
|
try { await video.requestPictureInPicture(); } catch { notify("暂时无法进入画中画"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFullscreen = async () => {
|
||||||
|
try { await videoShellRef.current?.requestFullscreen(); } catch { notify("暂时无法进入全屏"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTrack = async (index: number, autoPlay = true) => {
|
||||||
|
setTrackIndex(index);
|
||||||
|
setMusicPlaying(false);
|
||||||
|
if (autoPlay) window.setTimeout(() => audioRef.current?.play().catch(() => undefined), 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextTrack = () => {
|
||||||
|
const next = musicPlayMode === "shuffle" ? randomOtherIndex(tracks.length, trackIndex) : (trackIndex + 1) % tracks.length;
|
||||||
|
selectTrack(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousTrack = () => selectTrack((trackIndex - 1 + tracks.length) % tracks.length);
|
||||||
|
|
||||||
|
const handleTrackEnded = () => {
|
||||||
|
if (musicPlayMode === "single-loop" && audioRef.current) {
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
audioRef.current.play().catch(() => undefined);
|
||||||
|
} else if (musicPlayMode === "sequence" && trackIndex === tracks.length - 1) {
|
||||||
|
setMusicPlaying(false);
|
||||||
|
} else nextTrack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectVideo = (video: VideoItem, autoPlay = false) => {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
setCurrentVideoId(video.id);
|
||||||
|
setVideoTime(0);
|
||||||
|
setVideoDuration(0);
|
||||||
|
setVideoError(false);
|
||||||
|
setVideoPlaying(false);
|
||||||
|
if (autoPlay) window.setTimeout(() => videoRef.current?.play().catch(() => undefined), 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoEnded = () => {
|
||||||
|
const currentIndex = videos.findIndex((video) => video.id === currentVideo.id);
|
||||||
|
if (videoPlayMode === "single-loop" && videoRef.current) {
|
||||||
|
videoRef.current.currentTime = 0;
|
||||||
|
videoRef.current.play().catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (videoPlayMode === "sequence" && currentIndex === videos.length - 1) {
|
||||||
|
setVideoPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIndex = videoPlayMode === "shuffle" ? randomOtherIndex(videos.length, currentIndex) : (currentIndex + 1) % videos.length;
|
||||||
|
selectVideo(videos[nextIndex], true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorderTracks = (sourceId: string, targetId: string) => {
|
||||||
|
if (sourceId === targetId) return;
|
||||||
|
const activeId = currentTrack.id;
|
||||||
|
setTracks((current) => moveItem(current, sourceId, targetId));
|
||||||
|
setTrackIndex(moveItem(tracks, sourceId, targetId).findIndex((track) => track.id === activeId));
|
||||||
|
notify("歌单顺序已更新");
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorderVideos = (sourceId: string, targetId: string) => {
|
||||||
|
if (sourceId === targetId) return;
|
||||||
|
setVideos((current) => moveItem(current, sourceId, targetId));
|
||||||
|
notify("视频顺序已更新");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrackDrop = (event: DragEvent<HTMLButtonElement>, targetId: string) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sourceId = event.dataTransfer.getData("text/plain") || draggedTrackId;
|
||||||
|
if (sourceId) reorderTracks(sourceId, targetId);
|
||||||
|
setDraggedTrackId(null);
|
||||||
|
setTrackDropId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoDrop = (event: DragEvent<HTMLButtonElement>, targetId: string) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sourceId = event.dataTransfer.getData("text/plain") || draggedVideoId;
|
||||||
|
if (sourceId) reorderVideos(sourceId, targetId);
|
||||||
|
setDraggedVideoId(null);
|
||||||
|
setVideoDropId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadVideo = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
localUrlsRef.current.push(url);
|
||||||
|
videoRef.current?.pause();
|
||||||
|
const video: VideoItem = { id: `local-video-${Date.now()}`, title: file.name.replace(/\.[^.]+$/, ""), subtitle: "本地视频文件", src: url, poster: "/anime/star-echoes-hero.png", durationLabel: "本地", quality: file.type.split("/")[1]?.toUpperCase() || "VIDEO", views: "刚刚添加", local: true };
|
||||||
|
setVideos((current) => [...current, video]);
|
||||||
|
setCurrentVideoId(video.id);
|
||||||
|
setVideoTime(0);
|
||||||
|
setVideoDuration(0);
|
||||||
|
setVideoError(false);
|
||||||
|
setVideoPlaying(false);
|
||||||
|
event.target.value = "";
|
||||||
|
notify("本地视频已载入");
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadTracks = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []).filter((file) => file.type.startsWith("audio/"));
|
||||||
|
if (!files.length) return;
|
||||||
|
const additions = files.map<TrackItem>((file, index) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
localUrlsRef.current.push(url);
|
||||||
|
return { id: `local-track-${Date.now()}-${index}`, title: file.name.replace(/\.[^.]+$/, ""), artist: "本地音乐", album: "本地播放列表", src: url, cover: "/anime/star-echoes-merch.png", position: "50% center", durationLabel: "本地", lyrics: [], local: true };
|
||||||
|
});
|
||||||
|
setTracks((current) => [...current, ...additions]);
|
||||||
|
setTrackIndex(tracks.length);
|
||||||
|
event.target.value = "";
|
||||||
|
notify(`已添加 ${additions.length} 首本地音乐`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<Link className={styles.brand} href="/"><span>▶</span><div><strong>Orbit Player</strong><small>MEDIA EXPERIENCE</small></div></Link>
|
||||||
|
<div className={styles.modeTabs}><button className={mode === "video" ? styles.modeActive : ""} onClick={() => goToMode("video")}>▣ 视频播放器</button><button className={mode === "music" ? styles.modeActive : ""} onClick={() => goToMode("music")}>♫ 音乐播放器</button></div>
|
||||||
|
<div className={styles.headerActions}><span>HTML5 · CUSTOM UI</span><button onClick={() => mode === "video" ? videoUploadRef.current?.click() : audioUploadRef.current?.click()}>+ 添加本地{mode === "video" ? "视频" : "音乐"}</button><input ref={videoUploadRef} type="file" accept="video/*" onChange={uploadVideo} /><input ref={audioUploadRef} type="file" accept="audio/*" multiple onChange={uploadTracks} /></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{mode === "video" && <main className={styles.videoPage}>
|
||||||
|
<section className={styles.videoHeading}><div><p>VIDEO PLAYER</p><h1>{currentVideo.title}</h1><span>{currentVideo.subtitle}</span></div><div><b>{currentVideo.quality}</b><span>{currentVideo.views}</span></div></section>
|
||||||
|
<div className={styles.videoLayout}>
|
||||||
|
<div className={styles.videoColumn}>
|
||||||
|
<div ref={videoShellRef} className={styles.videoShell}>
|
||||||
|
<video key={currentVideo.src} ref={videoRef} src={currentVideo.src} poster={currentVideo.poster} playsInline onClick={toggleVideo} onPlay={() => setVideoPlaying(true)} onPause={() => setVideoPlaying(false)} onTimeUpdate={(event) => setVideoTime(event.currentTarget.currentTime)} onLoadedMetadata={(event) => { setVideoDuration(event.currentTarget.duration); event.currentTarget.playbackRate = speed; }} onEnded={handleVideoEnded} onError={() => setVideoError(true)} />
|
||||||
|
{!videoPlaying && !videoError && <button className={styles.centerPlay} onClick={toggleVideo} aria-label="播放视频">▶</button>}
|
||||||
|
{videoError && <div className={styles.mediaError}><span>!</span><strong>演示视频暂时无法加载</strong><p>可以点击下方按钮选择本地视频,播放器功能仍可完整使用。</p><button onClick={() => videoUploadRef.current?.click()}>选择本地视频</button></div>}
|
||||||
|
<div className={styles.videoShade} />
|
||||||
|
<div className={styles.videoControls}>
|
||||||
|
<input className={styles.progress} type="range" min="0" max={videoDuration || 100} step="0.01" value={Math.min(videoTime, videoDuration || 100)} onChange={(event) => changeVideoTime(Number(event.target.value))} aria-label="视频进度" />
|
||||||
|
<div><button onClick={toggleVideo} aria-label={videoPlaying ? "暂停" : "播放"}>{videoPlaying ? "Ⅱ" : "▶"}</button><button onClick={() => { if (videoRef.current) videoRef.current.currentTime -= 10; }} aria-label="后退十秒">↶ 10</button><span>{formatTime(videoTime)} / {formatTime(videoDuration)}</span><div className={styles.controlSpacer} /><button onClick={toggleMute} aria-label="静音">{muted ? "🔇" : "🔊"}</button><input className={styles.volume} type="range" min="0" max="1" step="0.01" value={muted ? 0 : volume} onChange={(event) => changeVolume(Number(event.target.value))} aria-label="音量" /><select value={speed} onChange={(event) => changeSpeed(Number(event.target.value))} aria-label="播放速度"><option value="0.75">0.75×</option><option value="1">1×</option><option value="1.25">1.25×</option><option value="1.5">1.5×</option><option value="2">2×</option></select><button onClick={openPictureInPicture} aria-label="画中画">▱</button><button onClick={openFullscreen} aria-label="全屏">⛶</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.videoMeta}><div><span>{currentVideo.local ? "LOCAL FILE" : "DEMO VIDEO"}</span><h2>{currentVideo.title}</h2><p>{currentVideo.subtitle}。支持进度拖动、倍速、音量、画中画和全屏播放。</p></div><button onClick={() => videoUploadRef.current?.click()}>替换视频</button></div>
|
||||||
|
</div>
|
||||||
|
<aside className={styles.videoPlaylist}><div><span>UP NEXT</span><strong>播放列表</strong><small>{videos.length} 个视频</small><label className={playlistStyles.videoMode}><i>播放模式</i><select value={videoPlayMode} onChange={(event) => setVideoPlayMode(event.target.value as PlayMode)}>{playModeOptions.map((item) => <option value={item.id} key={item.id}>{item.icon} {item.label}</option>)}</select></label></div>{videos.map((video, index) => <button draggable key={video.id} className={`${playlistStyles.draggableVideo} ${currentVideo.id === video.id ? styles.videoItemActive : ""} ${draggedVideoId === video.id ? playlistStyles.draggingItem : ""} ${videoDropId === video.id ? playlistStyles.dropTarget : ""}`} onDragStart={(event) => { setDraggedVideoId(video.id); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", video.id); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setVideoDropId(video.id); }} onDragLeave={() => setVideoDropId(null)} onDrop={(event) => handleVideoDrop(event, video.id)} onDragEnd={() => { setDraggedVideoId(null); setVideoDropId(null); }} onClick={() => selectVideo(video)}><em className={playlistStyles.dragHandle}>⋮⋮</em><span><img src={video.poster} alt="" /><i>{currentVideo.id === video.id && videoPlaying ? "Ⅱ" : "▶"}</i><b>{video.durationLabel}</b></span><p><strong>{video.title}</strong><small>{video.subtitle}</small><em>0{index + 1}</em></p></button>)}<button className={styles.addMedia} onClick={() => videoUploadRef.current?.click()}>+ 添加本地视频</button></aside>
|
||||||
|
</div>
|
||||||
|
</main>}
|
||||||
|
|
||||||
|
{mode === "music" && currentTrack && <main className={styles.musicPage}>
|
||||||
|
<audio key={currentTrack.id} ref={audioRef} src={currentTrack.src} onPlay={() => setMusicPlaying(true)} onPause={() => setMusicPlaying(false)} onTimeUpdate={(event) => setMusicTime(event.currentTarget.currentTime)} onLoadedMetadata={(event) => setMusicDuration(event.currentTarget.duration)} onEnded={handleTrackEnded} onError={() => setAudioError(true)} />
|
||||||
|
<section className={`${styles.musicHero} ${lyricStyles.musicHero}`} style={{ backgroundImage: `linear-gradient(110deg, rgba(8,12,25,.94), rgba(27,20,52,.75)), url(${currentTrack.cover})` }}>
|
||||||
|
<div className={`${styles.albumArt} ${musicPlaying ? styles.albumPlaying : ""}`}><img src={currentTrack.cover} alt={currentTrack.title} style={{ objectPosition: currentTrack.position }} /><i /></div>
|
||||||
|
<div className={styles.trackInfo}><p>NOW PLAYING</p><h1>{currentTrack.title}</h1><strong>{currentTrack.artist}</strong><span>{currentTrack.album}</span><div className={styles.waveform} aria-hidden="true">{waveform.map((height, index) => <i key={index} className={musicDuration && index / waveform.length <= musicTime / musicDuration ? styles.wavePlayed : ""} style={{ height: `${height}%` }} />)}</div><div className={styles.musicProgress}><span>{formatTime(musicTime)}</span><input type="range" min="0" max={musicDuration || 100} step="0.01" value={Math.min(musicTime, musicDuration || 100)} onChange={(event) => changeMusicTime(Number(event.target.value))} aria-label="音乐进度" /><span>{formatTime(musicDuration)}</span></div><div className={styles.musicControls}><button className={musicPlayMode === "shuffle" ? styles.controlActive : ""} onClick={() => setMusicPlayMode((value) => value === "shuffle" ? "sequence" : "shuffle")} aria-label="随机播放">⌘</button><button onClick={previousTrack} aria-label="上一首">◀</button><button className={styles.musicPlay} onClick={toggleMusic} aria-label={musicPlaying ? "暂停" : "播放"}>{musicPlaying ? "Ⅱ" : "▶"}</button><button onClick={nextTrack} aria-label="下一首">▶</button><button className={musicPlayMode === "single-loop" || musicPlayMode === "list-loop" ? styles.controlActive : ""} onClick={() => setMusicPlayMode((value) => value === "single-loop" ? "list-loop" : "single-loop")} aria-label="循环播放">↻</button></div>{audioError && <p className={styles.audioError}>演示音乐未能加载,可以选择本地音频继续体验播放器。</p>}</div>
|
||||||
|
<aside className={lyricStyles.lyricsPanel}>
|
||||||
|
<header><div><span>LYRICS</span><strong>同步歌词</strong></div><div className={lyricStyles.lyricVolume}><button onClick={toggleMute} aria-label="静音">{muted ? "🔇" : "🔊"}</button><input type="range" min="0" max="1" step="0.01" value={muted ? 0 : volume} onChange={(event) => changeVolume(Number(event.target.value))} aria-label="音乐音量" /></div></header>
|
||||||
|
{currentTrack.lyrics.length > 0 ? <div ref={lyricsRef} className={lyricStyles.lyricsScroll}>{currentTrack.lyrics.map((line, index) => <button key={`${currentTrack.id}-${line.time}`} data-lyric-index={index} className={index === activeLyricIndex ? lyricStyles.lyricActive : ""} onClick={() => changeMusicTime(line.time)}><strong>{line.text}</strong><small>{line.translation}</small></button>)}</div> : <div className={lyricStyles.lyricsEmpty}><span>♫</span><strong>暂无歌词</strong><small>为本地歌曲接入歌词数据后将在这里同步滚动。</small></div>}
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
<section className={styles.trackSection}><div className={styles.trackHeading}><div><p>PLAYLIST</p><h2>Orbit Night Radio</h2><span>{tracks.length} 首音乐 · 拖动曲目调整播放顺序</span></div><div className={playlistStyles.playlistActions}><label><span>播放模式</span><select value={musicPlayMode} onChange={(event) => setMusicPlayMode(event.target.value as PlayMode)}>{playModeOptions.map((item) => <option value={item.id} key={item.id}>{item.icon} {item.label}</option>)}</select></label><button onClick={() => audioUploadRef.current?.click()}>+ 添加本地音乐</button></div></div><div className={styles.trackList}>{tracks.map((track, index) => <button draggable key={track.id} className={`${playlistStyles.draggableTrack} ${index === trackIndex ? styles.trackActive : ""} ${draggedTrackId === track.id ? playlistStyles.draggingItem : ""} ${trackDropId === track.id ? playlistStyles.dropTarget : ""}`} onDragStart={(event) => { setDraggedTrackId(track.id); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", track.id); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setTrackDropId(track.id); }} onDragLeave={() => setTrackDropId(null)} onDrop={(event) => handleTrackDrop(event, track.id)} onDragEnd={() => { setDraggedTrackId(null); setTrackDropId(null); }} onClick={() => selectTrack(index)}><i className={playlistStyles.dragHandle}>⋮⋮</i><em>{index === trackIndex && musicPlaying ? "♫" : String(index + 1).padStart(2, "0")}</em><span className={styles.trackCover}><img src={track.cover} alt="" style={{ objectPosition: track.position }} /></span><p><strong>{track.title}</strong><small>{track.artist}</small></p><span>{track.album}</span><b>{track.durationLabel}</b></button>)}</div></section>
|
||||||
|
</main>}
|
||||||
|
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return "00:00";
|
||||||
|
const minutes = Math.floor(value / 60);
|
||||||
|
const seconds = Math.floor(value % 60);
|
||||||
|
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playModeOptions: { id: PlayMode; label: string; icon: string }[] = [
|
||||||
|
{ id: "sequence", label: "顺序播放", icon: "→" },
|
||||||
|
{ id: "list-loop", label: "列表循环", icon: "↻" },
|
||||||
|
{ id: "single-loop", label: "单个循环", icon: "↺" },
|
||||||
|
{ id: "shuffle", label: "随机播放", icon: "⌘" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function moveItem<T extends { id: string }>(items: T[], sourceId: string, targetId: string) {
|
||||||
|
const sourceIndex = items.findIndex((item) => item.id === sourceId);
|
||||||
|
const targetIndex = items.findIndex((item) => item.id === targetId);
|
||||||
|
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return items;
|
||||||
|
const next = [...items];
|
||||||
|
const [moved] = next.splice(sourceIndex, 1);
|
||||||
|
next.splice(targetIndex, 0, moved);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomOtherIndex(length: number, current: number) {
|
||||||
|
if (length <= 1) return current;
|
||||||
|
let next = current;
|
||||||
|
while (next === current) next = Math.floor(Math.random() * length);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
17
app/media-player/MediaPlaylist.module.css
Normal file
17
app/media-player/MediaPlaylist.module.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.playlistActions{display:flex;align-items:flex-end;gap:8px}
|
||||||
|
.playlistActions label{display:flex;flex-direction:column;gap:4px;color:#656d80;font-size:7px}
|
||||||
|
.playlistActions select{height:32px;padding:0 27px 0 9px;border:1px solid #2b3140;border-radius:6px;background:#121722;color:#b6bdcc;cursor:pointer;font-size:8px}
|
||||||
|
.playlistActions option{background:#121722}
|
||||||
|
.playlistActions>button{height:32px;padding:0 11px;border:1px solid #2b3140;border-radius:6px;background:#121722;color:#858ea1;cursor:pointer;font-size:8px}
|
||||||
|
.playlistActions>button:hover{border-color:#6e5acd;color:#fff}
|
||||||
|
.videoMode{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;margin-top:7px}
|
||||||
|
.videoMode i{color:#626a7d;font-size:7px;font-style:normal}
|
||||||
|
.videoMode select{height:28px;padding:0 7px;border:1px solid #2b3140;border-radius:5px;background:#121722;color:#aeb5c5;font-size:7px}
|
||||||
|
.videoMode option{background:#121722}
|
||||||
|
.draggableVideo{grid-template-columns:15px 92px 1fr!important;cursor:grab!important}
|
||||||
|
.draggableTrack{grid-template-columns:18px 35px 42px minmax(180px,1fr) minmax(140px,.7fr) 55px!important;cursor:grab!important}
|
||||||
|
.draggableVideo:active,.draggableTrack:active{cursor:grabbing!important}
|
||||||
|
.dragHandle{align-self:center;color:#4f576b;font-size:10px;font-style:normal;letter-spacing:-3px;text-align:center}
|
||||||
|
.draggingItem{opacity:.42!important}
|
||||||
|
.dropTarget{box-shadow:inset 0 2px 0 #8b6aff;background:rgba(139,106,255,.1)!important}
|
||||||
|
@media(max-width:720px){.playlistActions{align-items:stretch;flex-direction:column}.playlistActions label{flex-direction:row;align-items:center;justify-content:space-between}.draggableTrack{grid-template-columns:16px 28px 38px 1fr 44px!important}.draggableTrack>span:nth-of-type(2){display:none}.draggableVideo{grid-template-columns:15px 84px 1fr!important}}
|
||||||
5
app/media-player/page.tsx
Normal file
5
app/media-player/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import MediaPlayerExperience from "./MediaPlayerExperience";
|
||||||
|
|
||||||
|
export default function VideoPlayerPage() {
|
||||||
|
return <MediaPlayerExperience mode="video" />;
|
||||||
|
}
|
||||||
5
app/music-player/page.tsx
Normal file
5
app/music-player/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import MediaPlayerExperience from "../media-player/MediaPlayerExperience";
|
||||||
|
|
||||||
|
export default function MusicPlayerPage() {
|
||||||
|
return <MediaPlayerExperience mode="music" />;
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
.launcherStack{position:fixed;right:26px;bottom:26px;z-index:45;display:flex;flex-direction:column;gap:8px}.launcher{width:208px;min-height:55px;display:flex;align-items:center;gap:10px;padding:8px 11px;border:1px solid;border-radius:10px;box-shadow:0 13px 28px rgba(30,49,86,.2);color:#fff;text-decoration:none;transition:transform .18s ease,background .18s ease}.launcher:hover{transform:translateY(-2px)}.approval{border-color:#295fcf;background:#3276f6}.approval:hover{background:#215ecb}.worklist{border-color:#273a59;background:#17263d}.worklist:hover{background:#223655}.kanban{border-color:#47734f;background:#28613e}.kanban:hover{background:#1d5032}.bigscreen{border-color:#216c7c;background:#0d5365}.bigscreen:hover{background:#094757}.launcherIcon{width:33px;height:33px;display:grid;place-items:center;flex:0 0 auto;border-radius:8px;background:rgba(255,255,255,.14);font-size:15px}.launcher>span:nth-child(2){display:flex;flex:1;flex-direction:column;gap:3px}.launcher strong{font-size:10px}.launcher small{color:rgba(255,255,255,.7);font-size:8px}.launcher b{font-size:14px}@media(max-width:620px){.launcherStack{right:14px;bottom:14px;gap:6px}.launcher{width:48px;min-height:48px;padding:8px;border-radius:50%}.launcher>span:nth-child(2),.launcher b{display:none}.launcherIcon{width:30px;height:30px;background:transparent}}
|
.launcherStack{position:fixed;right:26px;bottom:26px;z-index:45;display:flex;flex-direction:column;gap:8px;max-height:calc(100vh - 52px);overflow-y:auto;padding:2px}.launcher{width:208px;min-height:55px;display:flex;align-items:center;gap:10px;padding:8px 11px;border:1px solid;border-radius:10px;box-shadow:0 13px 28px rgba(30,49,86,.2);color:#fff;text-decoration:none;transition:transform .18s ease,background .18s ease}.launcher:hover{transform:translateY(-2px)}.stargraph{border-color:#6959c6;background:#352a76}.stargraph:hover{background:#47399a}.icons{border-color:#39445e;background:#293246}.icons:hover{background:#354159}.repeatable{border-color:#3e69ce;background:#3158b4}.repeatable:hover{background:#284b9c}.markdown{border-color:#454859;background:#292b38}.markdown:hover{background:#363947}.character{border-color:#715bbd;background:#4b3c7b}.character:hover{background:#5a4893}.simplelist{border-color:#625cb5;background:#524c9a}.simplelist:hover{background:#625bb2}.anime{border-color:#6b57b8;background:#352867}.anime:hover{background:#463482}.approval{border-color:#295fcf;background:#3276f6}.approval:hover{background:#215ecb}.worklist{border-color:#273a59;background:#17263d}.worklist:hover{background:#223655}.kanban{border-color:#47734f;background:#28613e}.kanban:hover{background:#1d5032}.bigscreen{border-color:#216c7c;background:#0d5365}.bigscreen:hover{background:#094757}.launcherIcon{width:33px;height:33px;display:grid;place-items:center;flex:0 0 auto;border-radius:8px;background:rgba(255,255,255,.14);font-size:15px}.launcher>span:nth-child(2){display:flex;flex:1;flex-direction:column;gap:3px}.launcher strong{font-size:10px}.launcher small{color:rgba(255,255,255,.7);font-size:8px}.launcher b{font-size:14px}@media(max-width:620px){.launcherStack{right:14px;bottom:14px;gap:6px}.launcher{width:48px;min-height:48px;padding:8px;border-radius:50%}.launcher>span:nth-child(2),.launcher b{display:none}.launcherIcon{width:30px;height:30px;background:transparent}}
|
||||||
|
|||||||
35
app/page.tsx
35
app/page.tsx
@@ -1,34 +1,5 @@
|
|||||||
import Link from "next/link";
|
import TemplateHub from "./TemplateHub";
|
||||||
import AdminApp from "./AdminApp";
|
|
||||||
import styles from "./page.module.css";
|
|
||||||
|
|
||||||
// Feature routes use localStorage-backed JSON demo data.
|
export default function HomePage() {
|
||||||
export default function Home() {
|
return <TemplateHub />;
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AdminApp />
|
|
||||||
<nav className={styles.launcherStack} aria-label="模板功能入口">
|
|
||||||
<Link className={`${styles.launcher} ${styles.bigscreen}`} href="/bigscreen">
|
|
||||||
<span className={styles.launcherIcon}>▥</span>
|
|
||||||
<span><strong>数据大屏</strong><small>经营指挥中心</small></span>
|
|
||||||
<b>→</b>
|
|
||||||
</Link>
|
|
||||||
<Link className={`${styles.launcher} ${styles.kanban}`} href="/kanban">
|
|
||||||
<span className={styles.launcherIcon}>▦</span>
|
|
||||||
<span><strong>泳道看板</strong><small>拖拽变更卡片状态</small></span>
|
|
||||||
<b>→</b>
|
|
||||||
</Link>
|
|
||||||
<Link className={`${styles.launcher} ${styles.worklist}`} href="/worklist">
|
|
||||||
<span className={styles.launcherIcon}>☷</span>
|
|
||||||
<span><strong>工作列表</strong><small>多级树形表格</small></span>
|
|
||||||
<b>→</b>
|
|
||||||
</Link>
|
|
||||||
<Link className={`${styles.launcher} ${styles.approval}`} href="/approval">
|
|
||||||
<span className={styles.launcherIcon}>⌘</span>
|
|
||||||
<span><strong>审批流程设计</strong><small>可视化编辑器</small></span>
|
|
||||||
<b>→</b>
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/produce-map/ProduceMap.module.css
Normal file
15
app/produce-map/ProduceMap.module.css
Normal file
File diff suppressed because one or more lines are too long
266
app/produce-map/page.tsx
Normal file
266
app/produce-map/page.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CSSProperties, useEffect, useMemo, useState } from "react";
|
||||||
|
import { geoConicConformal, geoIdentity, geoPath } from "d3-geo";
|
||||||
|
import mapData from "../data/produce-map.json";
|
||||||
|
import styles from "./ProduceMap.module.css";
|
||||||
|
|
||||||
|
type Region = { name: string; localName: string; crops: string[]; season: string; summary: string };
|
||||||
|
type Tab = { id: string; label: string; country: string; iso: string; map: string; accent: string; description: string; regions: Region[] };
|
||||||
|
type Feature = { type: "Feature"; properties: { shapeName?: string }; geometry: unknown };
|
||||||
|
type FeatureCollection = { type: "FeatureCollection"; features: Feature[] };
|
||||||
|
type MapShape = { feature: Feature; d: string; centroid: [number, number]; name: string; key: string };
|
||||||
|
type DrilldownArea = { name: string; localName: string; crops: string[]; season: string; summary: string };
|
||||||
|
|
||||||
|
const tabs = mapData.tabs as Tab[];
|
||||||
|
const hebeiCities: DrilldownArea[] = [
|
||||||
|
{ name: "Shijiazhuang City", localName: "石家庄市", crops: ["小麦", "玉米", "梨", "乳制品"], season: "6月—10月", summary: "太行山东麓平原农业集中,是河北中南部粮食与果品生产区。" },
|
||||||
|
{ name: "Tangshan City", localName: "唐山市", crops: ["水稻", "玉米", "花生", "板栗"], season: "7月—10月", summary: "燕山南麓与滨海平原兼具粮食、油料和京东板栗等特色产品。" },
|
||||||
|
{ name: "Qinhuangdao City", localName: "秦皇岛市", crops: ["葡萄", "板栗", "苹果", "水产"], season: "7月—10月", summary: "山海相邻,昌黎葡萄、燕山板栗及沿海水产品具有代表性。" },
|
||||||
|
{ name: "Handan City", localName: "邯郸市", crops: ["小麦", "玉米", "棉花", "核桃"], season: "6月—10月", summary: "冀南平原适合粮棉种植,西部山区发展核桃等林果产品。" },
|
||||||
|
{ name: "Xingtai City", localName: "邢台市", crops: ["板栗", "苹果", "小麦", "酸枣"], season: "6月—10月", summary: "太行山区林果与东部平原粮食生产并行,酸枣产业特色明显。" },
|
||||||
|
{ name: "Baoding City", localName: "保定市", crops: ["草莓", "桃", "柿子", "小麦"], season: "5月—10月", summary: "山地、丘陵和平原类型齐全,形成多样化果品和粮食产区。" },
|
||||||
|
{ name: "Zhangjiakou City", localName: "张家口市", crops: ["马铃薯", "燕麦", "杂豆", "葡萄"], season: "7月—10月", summary: "坝上高原气候冷凉,是马铃薯、燕麦和错季蔬菜优势产区。" },
|
||||||
|
{ name: "Chengde City", localName: "承德市", crops: ["山楂", "板栗", "食用菌", "杂粮"], season: "7月—10月", summary: "燕山山地森林资源丰富,林果、菌类和旱作杂粮较有特色。" },
|
||||||
|
{ name: "Cangzhou City", localName: "沧州市", crops: ["金丝小枣", "鸭梨", "玉米", "水产"], season: "8月—10月", summary: "滨海平原和盐碱地农业突出,以金丝小枣、鸭梨及水产著名。" },
|
||||||
|
{ name: "Langfang City", localName: "廊坊市", crops: ["蔬菜", "梨", "葡萄", "食用菌"], season: "5月—11月", summary: "京津之间的都市近郊农业区,设施蔬菜、果品和菌类供应集中。" },
|
||||||
|
{ name: "Hengshui City", localName: "衡水市", crops: ["小麦", "玉米", "棉花", "辣椒"], season: "6月—10月", summary: "黑龙港流域平原农业为主,粮棉与特色蔬菜种植规模较大。" }
|
||||||
|
];
|
||||||
|
const regionTranslations: Record<string, Record<string, string>> = {
|
||||||
|
china: {
|
||||||
|
"Shanghai Municipality": "上海市",
|
||||||
|
"Chongqing Municipality": "重庆市"
|
||||||
|
},
|
||||||
|
japan: {
|
||||||
|
"Aichi Prefecture": "爱知县", Akita: "秋田县", Aomori: "青森县", Chiba: "千叶县", "Ehime Prefecture": "爱媛县", "Fukui Prefecture": "福井县", "Fukuoka Prefecture": "福冈县", Fukushima: "福岛县", "Gifu Prefecture": "岐阜县", Gunma: "群马县", Hiroshima: "广岛县", Hokkaido: "北海道", "Hyogo Prefecture": "兵库县", Ibaraki: "茨城县", "Ishikawa Prefecture": "石川县", Iwate: "岩手县", "Kagawa Prefecture": "香川县", "Kagoshima Prefecture": "鹿儿岛县", Kanagawa: "神奈川县", "Kochi Prefecture": "高知县", Kumamoto: "熊本县", "Kyoto Prefecture": "京都府", "Mie Prefecture": "三重县", Miyagi: "宫城县", "Miyazaki Prefecture": "宫崎县", Nagano: "长野县", "Nagasaki Prefecture": "长崎县", "Nara Prefecture": "奈良县", Niigata: "新潟县", Oita: "大分县", "Okayama Prefecture": "冈山县", "Okinawa Prefecture": "冲绳县", "Osaka Prefecture": "大阪府", "Saga Prefecture": "佐贺县", Saitama: "埼玉县", Shiga: "滋贺县", Shimane: "岛根县", Shizuoka: "静冈县", Tochigi: "栃木县", "Tokushima Prefecture": "德岛县", Tokyo: "东京都", "Tottori Prefecture": "鸟取县", Toyama: "富山县", "Wakayama Prefecture": "和歌山县", Yamagata: "山形县", Yamaguchi: "山口县", Yamanashi: "山梨县"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProduceMapPage() {
|
||||||
|
const [activeId, setActiveId] = useState(tabs[0].id);
|
||||||
|
const [geoData, setGeoData] = useState<FeatureCollection | null>(null);
|
||||||
|
const [selectedName, setSelectedName] = useState(tabs[0].regions[0].name);
|
||||||
|
const [selectedMapName, setSelectedMapName] = useState(tabs[0].regions[0].name);
|
||||||
|
const [hoveredName, setHoveredName] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [hebeiGeoData, setHebeiGeoData] = useState<FeatureCollection | null>(null);
|
||||||
|
const [selectedHebeiCity, setSelectedHebeiCity] = useState(hebeiCities[0].name);
|
||||||
|
const [hoveredHebeiCity, setHoveredHebeiCity] = useState<string | null>(null);
|
||||||
|
const active = tabs.find((item) => item.id === activeId) ?? tabs[0];
|
||||||
|
const selected = active.regions.find((item) => item.name === selectedName) ?? null;
|
||||||
|
const selectedDisplayName = selected?.localName ?? localRegionName(active.id, selectedMapName);
|
||||||
|
const cropNames = useMemo(() => Array.from(new Set(active.regions.flatMap((item) => item.crops))), [active]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setGeoData(null);
|
||||||
|
setSelectedName(active.regions[0].name);
|
||||||
|
setSelectedMapName(active.regions[0].name);
|
||||||
|
fetch(active.map).then((response) => response.json()).then((data: FeatureCollection) => {
|
||||||
|
if (!cancelled) { setGeoData(data); setLoading(false); }
|
||||||
|
}).catch(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
const mapShapes = useMemo(() => {
|
||||||
|
if (!geoData) return [];
|
||||||
|
return buildMapShapes(geoData, active.id);
|
||||||
|
}, [geoData, active.id]);
|
||||||
|
const selectedShape = mapShapes.find((shape) => shape.name === selectedMapName);
|
||||||
|
const showHebeiDrilldown = active.id === "china" && selectedMapName === "Hebei Province";
|
||||||
|
const hebeiShapes = useMemo(() => hebeiGeoData ? projectMapFeatures(hebeiGeoData.features, [[44, 42], [956, 598]], "hebei-city") : [], [hebeiGeoData]);
|
||||||
|
const selectedCity = hebeiCities.find((city) => city.name === selectedHebeiCity) ?? hebeiCities[0];
|
||||||
|
const selectedCityShape = hebeiShapes.find((shape) => shape.name === selectedHebeiCity);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/maps/hebei-adm2.geojson").then((response) => response.json()).then((data: FeatureCollection) => setHebeiGeoData(data)).catch(() => setHebeiGeoData(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showHebeiDrilldown) return;
|
||||||
|
const frame = requestAnimationFrame(() => document.getElementById("province-detail")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [showHebeiDrilldown]);
|
||||||
|
|
||||||
|
const activateTab = (tab: Tab) => { setActiveId(tab.id); setHoveredName(null); };
|
||||||
|
const selectMappedRegion = (name: string) => {
|
||||||
|
setSelectedMapName(name);
|
||||||
|
setSelectedName(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page} style={{ "--accent": active.accent } as CSSProperties}>
|
||||||
|
<header className={styles.topbar}>
|
||||||
|
<Link href="/" className={styles.brand}><span>产</span><div><strong>Terra Produce</strong><small>GLOBAL ORIGIN ATLAS</small></div></Link>
|
||||||
|
<nav><Link href="/">模板主页</Link><a href="#atlas">产物地图</a><a href="#regions">产区清单</a></nav>
|
||||||
|
<div className={styles.status}><i />本地地图与 JSON 数据</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<div><p>GLOBAL PRODUCE MAP</p><h1>全球主要产物地图</h1><span>按地区浏览国家地图,高亮到省、州、都道府县等一级行政区,点击查看该产区的代表作物。</span></div>
|
||||||
|
<div className={styles.metrics}><article><small>地区入口</small><strong>{tabs.length}</strong><span>全球区域</span></article><article><small>示例产区</small><strong>{tabs.reduce((sum, item) => sum + item.regions.length, 0)}</strong><span>一级行政区</span></article><article><small>当前作物</small><strong>{cropNames.length}</strong><span>{active.country}</span></article></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.atlas} id="atlas">
|
||||||
|
<div className={styles.tabs} role="tablist" aria-label="地区地图">
|
||||||
|
{tabs.map((tab) => <button role="tab" aria-selected={active.id === tab.id} key={tab.id} className={active.id === tab.id ? styles.activeTab : ""} onClick={() => activateTab(tab)}>{tab.label}<small>{tab.country}</small></button>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.mapLayout}>
|
||||||
|
<section className={styles.mapPanel}>
|
||||||
|
<header><div><small>{active.iso} · ADMIN LEVEL 1</small><h2>{active.label} · {active.country}</h2><p>{active.description}</p></div><div className={styles.legend}><span><i />主要产区</span><span><i />其他行政区</span></div></header>
|
||||||
|
<div className={styles.mapCanvas}>
|
||||||
|
{loading && <div className={styles.mapLoading}><span /><p>正在加载行政区地图…</p></div>}
|
||||||
|
{!loading && !geoData && <div className={styles.mapLoading}><b>!</b><p>地图数据加载失败,请刷新重试。</p></div>}
|
||||||
|
{geoData && <svg viewBox="0 0 1000 640" role="img" aria-label={`${active.country}一级行政区产物地图`}>
|
||||||
|
<defs><filter id="mapGlow"><feGaussianBlur stdDeviation="7" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter></defs>
|
||||||
|
{active.id === "usa" && <g className={styles.usaInsets} aria-hidden="true">
|
||||||
|
<rect x="44" y="462" width="224" height="156" rx="10" /><text x="58" y="482">阿拉斯加</text>
|
||||||
|
<rect x="282" y="520" width="142" height="98" rx="10" /><text x="296" y="540">夏威夷</text>
|
||||||
|
<rect x="440" y="546" width="116" height="72" rx="10" /><text x="452" y="566">波多黎各</text>
|
||||||
|
</g>}
|
||||||
|
<g className={styles.mapViewport}>{mapShapes.map(({ d, name, key }) => {
|
||||||
|
const marked = active.regions.some((region) => region.name === name);
|
||||||
|
const isSelected = selectedMapName === name;
|
||||||
|
return <path key={key} d={d} tabIndex={0} role="button" className={`${styles.mapRegion} ${marked ? styles.markedRegion : ""} ${isSelected ? styles.selectedRegion : ""}`} onClick={() => selectMappedRegion(name)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") selectMappedRegion(name); }} onMouseEnter={() => setHoveredName(name)} onMouseLeave={() => setHoveredName(null)} aria-pressed={isSelected} aria-label={`选择${name}`} />;
|
||||||
|
})}{selectedShape && <g className={styles.mapLabel} transform={`translate(${selectedShape.centroid[0]} ${selectedShape.centroid[1]})`} aria-hidden="true"><circle r="7" /><path d="M0 7 L0 22" /><rect x="-38" y="22" width="76" height="25" rx="7" /><text y="39">{selectedDisplayName}</text></g>}</g>
|
||||||
|
</svg>}
|
||||||
|
{hoveredName && <div className={styles.mapTooltip}><strong>{active.regions.find((item) => item.name === hoveredName)?.localName ?? localRegionName(active.id, hoveredName)}</strong><small>{hoveredName}</small><span>{active.regions.some((item) => item.name === hoveredName) ? "点击高亮并查看主要产物" : "点击高亮,作物资料待补充"}</span></div>}
|
||||||
|
<div className={styles.mapHint}>点击彩色行政区查看主要作物</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className={styles.detailPanel}>
|
||||||
|
<header><small>SELECTED REGION</small><span>{active.iso}</span><h2>{selectedDisplayName}</h2><p>{selectedMapName}</p></header>
|
||||||
|
<div className={styles.detailBody}>
|
||||||
|
{selected ? <section><span>代表作物</span><div className={styles.cropGrid}>{selected.crops.map((crop, index) => <article key={crop}><b>{cropIcon(crop)}</b><div><strong>{crop}</strong><small>主要产物 {String(index + 1).padStart(2, "0")}</small></div></article>)}</div></section> : <section className={styles.emptyProduce}><b>+</b><span>暂无作物标记</span><p>该一级行政区已选中并高亮,可继续在 JSON 数据中补充主要产物。</p></section>}
|
||||||
|
<dl><div><dt>主要产季</dt><dd>{selected?.season ?? "待补充"}</dd></div><div><dt>行政层级</dt><dd>一级行政区</dd></div><div><dt>所属国家</dt><dd>{active.country}</dd></div></dl>
|
||||||
|
<section className={styles.originNote}><span>产区说明</span><p>{selected?.summary ?? `${selectedMapName}当前暂无主要产物说明,已保留完整点击与高亮交互。`}</p></section>
|
||||||
|
</div>
|
||||||
|
<footer><button onClick={() => document.getElementById("regions")?.scrollIntoView({ behavior: "smooth" })}>查看全部重点产区 ↓</button></footer>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{showHebeiDrilldown && <section className={styles.drilldownSection} id="province-detail">
|
||||||
|
<div className={styles.drilldownHeader}>
|
||||||
|
<div><p>PROVINCE DRILLDOWN · ADM2</p><h2>河北省 · 市级行政单位</h2><span>从全国一级行政区继续下钻,点击市级边界查看当地代表产物。</span></div>
|
||||||
|
<button onClick={() => document.getElementById("atlas")?.scrollIntoView({ behavior: "smooth" })}>↑ 返回中国地图</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.mapLayout}>
|
||||||
|
<section className={styles.mapPanel}>
|
||||||
|
<header><div><small>HEBEI · ADMIN LEVEL 2</small><h2>河北省市级地图</h2><p>共 11 个地级市,保持与国家地图一致的点击与高亮方式。</p></div><div className={styles.legend}><span><i />已选城市</span><span><i />其他城市</span></div></header>
|
||||||
|
<div className={styles.mapCanvas}>
|
||||||
|
{hebeiGeoData ? <svg viewBox="0 0 1000 640" role="img" aria-label="河北省市级行政区产物地图">
|
||||||
|
<defs><filter id="cityGlow"><feGaussianBlur stdDeviation="7" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter></defs>
|
||||||
|
<g className={styles.mapViewport}>{hebeiShapes.map(({ d, name, key }) => {
|
||||||
|
const isSelected = selectedHebeiCity === name;
|
||||||
|
return <path key={key} d={d} tabIndex={0} role="button" className={`${styles.mapRegion} ${styles.markedRegion} ${isSelected ? styles.selectedRegion : ""}`} onClick={() => setSelectedHebeiCity(name)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") setSelectedHebeiCity(name); }} onMouseEnter={() => setHoveredHebeiCity(name)} onMouseLeave={() => setHoveredHebeiCity(null)} aria-pressed={isSelected} aria-label={`选择${hebeiCities.find((city) => city.name === name)?.localName ?? name}`} />;
|
||||||
|
})}{selectedCityShape && <g className={styles.mapLabel} transform={`translate(${selectedCityShape.centroid[0]} ${selectedCityShape.centroid[1]})`} aria-hidden="true"><circle r="7" /><path d="M0 7 L0 22" /><rect x="-42" y="22" width="84" height="25" rx="7" /><text y="39">{selectedCity.localName}</text></g>}</g>
|
||||||
|
</svg> : <div className={styles.mapLoading}><span /><p>正在加载河北省市级地图…</p></div>}
|
||||||
|
{hoveredHebeiCity && <div className={styles.mapTooltip}><strong>{hebeiCities.find((city) => city.name === hoveredHebeiCity)?.localName ?? hoveredHebeiCity}</strong><small>{hoveredHebeiCity}</small><span>点击高亮并查看市级产物</span></div>}
|
||||||
|
<div className={styles.mapHint}>点击市级区域查看主要产物</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<aside className={styles.detailPanel}>
|
||||||
|
<header><small>SELECTED CITY</small><span>HEB</span><h2>{selectedCity.localName}</h2><p>{selectedCity.name}</p></header>
|
||||||
|
<div className={styles.detailBody}>
|
||||||
|
<section><span>代表产物</span><div className={styles.cropGrid}>{selectedCity.crops.map((crop, index) => <article key={crop}><b>{cropIcon(crop)}</b><div><strong>{crop}</strong><small>市级产物 {String(index + 1).padStart(2, "0")}</small></div></article>)}</div></section>
|
||||||
|
<dl><div><dt>主要产季</dt><dd>{selectedCity.season}</dd></div><div><dt>行政层级</dt><dd>地级市</dd></div><div><dt>所属地区</dt><dd>中国 · 河北省</dd></div></dl>
|
||||||
|
<section className={styles.originNote}><span>产区说明</span><p>{selectedCity.summary}</p></section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<div className={styles.cityList}>{hebeiCities.map((city, index) => <button key={city.name} className={selectedHebeiCity === city.name ? styles.activeCity : ""} onClick={() => setSelectedHebeiCity(city.name)}><span>{String(index + 1).padStart(2, "0")}</span><div><small>{city.name}</small><strong>{city.localName}</strong></div><b>{city.crops[0]} · {city.crops[1]}</b></button>)}</div>
|
||||||
|
</section>}
|
||||||
|
|
||||||
|
<section className={styles.regionSection} id="regions">
|
||||||
|
<div className={styles.sectionTitle}><div><p>PRIMARY ORIGINS</p><h2>{active.country}重点产区</h2></div><span>{active.regions.length} 个示例行政区 · 点击可同步高亮地图</span></div>
|
||||||
|
<div className={styles.regionGrid}>{active.regions.map((region, index) => <button key={region.name} className={selectedMapName === region.name ? styles.activeRegionCard : ""} onClick={() => { setSelectedName(region.name); setSelectedMapName(region.name); document.getElementById("atlas")?.scrollIntoView({ behavior: "smooth" }); }}><span>{String(index + 1).padStart(2, "0")}</span><div><small>{region.name}</small><strong>{region.localName}</strong><p>{region.summary}</p><footer>{region.crops.map((crop) => <i key={crop}>{crop}</i>)}</footer></div><b>地图定位 ↗</b></button>)}</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cropIcon(crop: string) {
|
||||||
|
if (/苹果|梨|樱桃|葡萄|芒果|橙|柑|莓|瓜|果|香蕉|菠萝|荔枝/.test(crop)) return "果";
|
||||||
|
if (/小麦|大麦|玉米|水稻|燕麦|荞麦|大豆|油菜|豆|扁豆|豌豆/.test(crop)) return "谷";
|
||||||
|
if (/茶|咖啡|可可|啤酒花|葡萄酒/.test(crop)) return "饮";
|
||||||
|
if (/奶|乳|猪|水产/.test(crop)) return "鲜";
|
||||||
|
return "植";
|
||||||
|
}
|
||||||
|
|
||||||
|
function localRegionName(tabId: string, name: string) {
|
||||||
|
return regionTranslations[tabId]?.[name] ?? name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectMapFeatures(features: Feature[], extent: [[number, number], [number, number]], keyPrefix = "map"): MapShape[] {
|
||||||
|
if (!features.length) return [];
|
||||||
|
// geoBoundaries uses planar GeoJSON rings. The reflected identity projection
|
||||||
|
// preserves the real outlines without treating the ring winding as a globe.
|
||||||
|
const collection: FeatureCollection = { type: "FeatureCollection", features };
|
||||||
|
const projection = geoIdentity().reflectY(true).fitExtent(extent, collection as never);
|
||||||
|
const path = geoPath(projection);
|
||||||
|
return features.map((feature, index) => ({
|
||||||
|
feature,
|
||||||
|
d: path(feature as never) ?? "",
|
||||||
|
centroid: path.centroid(feature as never) as [number, number],
|
||||||
|
name: feature.properties.shapeName ?? "未命名区域",
|
||||||
|
key: `${keyPrefix}-${feature.properties.shapeName ?? index}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMapShapes(data: FeatureCollection, tabId: string): MapShape[] {
|
||||||
|
if (tabId === "russia") return projectRussiaFeatures(data.features);
|
||||||
|
if (tabId !== "usa") return projectMapFeatures(data.features, [[44, 42], [956, 598]]);
|
||||||
|
|
||||||
|
const byName = (names: string[]) => data.features.filter((feature) => names.includes(feature.properties.shapeName ?? ""));
|
||||||
|
const detached = new Set([
|
||||||
|
"Alaska", "Hawaii", "Puerto Rico", "United States Virgin Islands",
|
||||||
|
"American Samoa", "Guam", "Commonwealth of the Northern Mariana Islands"
|
||||||
|
]);
|
||||||
|
const mainland = data.features.filter((feature) => !detached.has(feature.properties.shapeName ?? ""));
|
||||||
|
|
||||||
|
// The contiguous states fill the canvas. Detached states and territories are
|
||||||
|
// placed in compact insets so they no longer shrink the main US silhouette.
|
||||||
|
return [
|
||||||
|
...projectMapFeatures(mainland, [[34, 48], [966, 520]], "usa-main"),
|
||||||
|
...projectMapFeatures(byName(["Alaska"]), [[52, 472], [260, 610]], "usa-alaska"),
|
||||||
|
...projectMapFeatures(byName(["Hawaii"]), [[290, 532], [416, 610]], "usa-hawaii"),
|
||||||
|
...projectMapFeatures(byName(["Puerto Rico", "United States Virgin Islands"]), [[448, 570], [548, 610]], "usa-caribbean"),
|
||||||
|
...projectMapFeatures(byName(["American Samoa"]), [[588, 574], [650, 610]], "usa-samoa"),
|
||||||
|
...projectMapFeatures(byName(["Guam"]), [[680, 574], [718, 610]], "usa-guam"),
|
||||||
|
...projectMapFeatures(byName(["Commonwealth of the Northern Mariana Islands"]), [[750, 570], [810, 610]], "usa-mariana")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectRussiaFeatures(features: Feature[]): MapShape[] {
|
||||||
|
const projectedFeatures = features.map(reverseSphericalRings);
|
||||||
|
const collection: FeatureCollection = { type: "FeatureCollection", features: projectedFeatures };
|
||||||
|
const projection = geoConicConformal().parallels([50, 70]).rotate([-100, 0]).fitExtent([[34, 42], [966, 598]], collection as never);
|
||||||
|
const path = geoPath(projection);
|
||||||
|
return projectedFeatures.map((feature, index) => ({
|
||||||
|
feature,
|
||||||
|
d: path(feature as never) ?? "",
|
||||||
|
centroid: path.centroid(feature as never) as [number, number],
|
||||||
|
name: feature.properties.shapeName ?? "未命名区域",
|
||||||
|
key: `russia-${feature.properties.shapeName ?? index}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseSphericalRings(feature: Feature): Feature {
|
||||||
|
const geometry = feature.geometry as { type?: string; coordinates?: unknown };
|
||||||
|
if (geometry.type === "Polygon") {
|
||||||
|
const coordinates = geometry.coordinates as number[][][];
|
||||||
|
return { ...feature, geometry: { ...geometry, coordinates: coordinates.map((ring) => [...ring].reverse()) } };
|
||||||
|
}
|
||||||
|
if (geometry.type === "MultiPolygon") {
|
||||||
|
const coordinates = geometry.coordinates as number[][][][];
|
||||||
|
return { ...feature, geometry: { ...geometry, coordinates: coordinates.map((polygon) => polygon.map((ring) => [...ring].reverse())) } };
|
||||||
|
}
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
246
app/repeatable-form/RepeatableForm.module.css
Normal file
246
app/repeatable-form/RepeatableForm.module.css
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 10px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||||
|
|
||||||
|
.page {
|
||||||
|
--ink: #20283a;
|
||||||
|
--muted: #8790a2;
|
||||||
|
--line: #e5e8ef;
|
||||||
|
--blue: #3d6fe8;
|
||||||
|
--blue-dark: #2d5dcc;
|
||||||
|
--blue-soft: #edf3ff;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page * { box-sizing: border-box; }
|
||||||
|
.page button, .page input, .page select, .page textarea { font: inherit; }
|
||||||
|
.topbar { position: sticky; top: 0; z-index: 30; height: 64px; display: flex; align-items: center; padding: 0 24px; border-bottom: 1px solid #e3e7ee; background: rgba(255,255,255,.96); backdrop-filter: blur(12px); }
|
||||||
|
.brand { display: flex; align-items: center; gap: 9px; color: #20283a; text-decoration: none; }
|
||||||
|
.brand > span { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 8px; background: linear-gradient(145deg,#4778ed,#315cc8); color: #fff; font-family: Georgia,serif; font-size: 15px; box-shadow: 0 5px 12px rgba(50,91,199,.24); }
|
||||||
|
.brand strong { font-size: 13px; letter-spacing: -.2px; }
|
||||||
|
.topbar nav { height: 100%; display: flex; align-items: stretch; gap: 4px; margin-left: 42px; }
|
||||||
|
.topbar nav a { position: relative; display: flex; align-items: center; padding: 0 14px; color: #8a92a1; text-decoration: none; font-size: 10px; }
|
||||||
|
.topbar nav a:hover, .topbar nav .activeNav { color: #31415e; }
|
||||||
|
.topbar nav .activeNav::after { content: ""; position: absolute; inset: auto 13px -1px; height: 2px; background: var(--blue); }
|
||||||
|
.saveState { display: flex; align-items: center; gap: 6px; margin-left: auto; color: #9098a7; font-size: 8px; }
|
||||||
|
.saveState > i, .formFooter p > i { width: 6px; height: 6px; border-radius: 50%; background: #e5a94d; }
|
||||||
|
.saveState .savedDot, .formFooter p > .savedDot { background: #31a670; }
|
||||||
|
.headerActions { display: flex; align-items: center; gap: 7px; margin-left: 20px; }
|
||||||
|
.headerActions button { height: 32px; padding: 0 12px; border: 1px solid #dfe3ea; border-radius: 7px; background: #fff; color: #616d80; cursor: pointer; font-size: 9px; }
|
||||||
|
.headerActions button:hover { border-color: #b8c8e9; }
|
||||||
|
.headerActions .saveButton { border-color: var(--blue); background: var(--blue); color: #fff; box-shadow: 0 5px 13px rgba(61,111,232,.2); }
|
||||||
|
.headerActions > span { width: 30px; height: 30px; display: grid; place-items: center; margin-left: 4px; border-radius: 50%; background: #dfe9ff; color: #315daa; font-size: 9px; font-weight: 700; }
|
||||||
|
|
||||||
|
.workspace { min-height: calc(100vh - 64px); display: grid; grid-template-columns: 256px minmax(0,1fr); }
|
||||||
|
.sidebar { position: sticky; top: 64px; height: calc(100vh - 64px); display: flex; flex-direction: column; border-right: 1px solid #e2e6ed; background: #fff; }
|
||||||
|
.sidebarTitle { height: 70px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px 0 19px; }
|
||||||
|
.sidebarTitle p { margin: 0 0 3px; color: #7891cc; font-size: 6px; font-weight: 800; letter-spacing: 1.3px; }
|
||||||
|
.sidebarTitle strong { font-size: 13px; }
|
||||||
|
.sidebarTitle button { width: 30px; height: 30px; border: 0; border-radius: 7px; background: var(--blue); color: #fff; cursor: pointer; font-size: 16px; }
|
||||||
|
.search { height: 33px; display: flex; align-items: center; gap: 7px; margin: 0 14px 11px; padding: 0 9px; border: 1px solid #e1e5eb; border-radius: 7px; background: #f8f9fb; color: #9da5b2; }
|
||||||
|
.search:focus-within { border-color: #9db7ef; background: #fff; box-shadow: 0 0 0 3px rgba(61,111,232,.07); }
|
||||||
|
.search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 8px; }
|
||||||
|
.statusTabs { display: flex; gap: 3px; padding: 0 13px 11px; border-bottom: 1px solid #eef0f3; overflow-x: auto; }
|
||||||
|
.statusTabs button { padding: 5px 7px; border: 0; border-radius: 5px; background: none; color: #9aa1ad; cursor: pointer; font-size: 7px; white-space: nowrap; }
|
||||||
|
.statusTabs .statusTabActive { background: var(--blue-soft); color: #3a67cf; font-weight: 700; }
|
||||||
|
.formList { flex: 1; overflow-y: auto; padding: 9px; }
|
||||||
|
.formList > button { width: 100%; display: flex; align-items: flex-start; gap: 9px; margin-bottom: 5px; padding: 11px 9px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: #5b6576; cursor: pointer; text-align: left; }
|
||||||
|
.formList > button:hover { background: #f7f9fc; }
|
||||||
|
.formList > .formActive { border-color: #d8e3fb; background: #f1f5ff; box-shadow: inset 3px 0 #4776e5; }
|
||||||
|
.formIcon { width: 31px; height: 31px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 7px; background: #edf0f5; color: #7a879a; font-size: 12px; }
|
||||||
|
.formActive .formIcon { background: #dfe9ff; color: #3d6bd5; }
|
||||||
|
.formList > button > div { min-width: 0; flex: 1; }
|
||||||
|
.formList strong { display: block; overflow: hidden; color: #3e4859; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.formList small { display: block; margin: 3px 0 7px; color: #a1a8b4; font-size: 6px; }
|
||||||
|
.formList p { display: flex; align-items: center; gap: 4px; margin: 0; color: #8f97a5; font-size: 6px; }
|
||||||
|
.formList p > i { width: 5px; height: 5px; border-radius: 50%; background: #9ca4b0; }
|
||||||
|
.formList p > .recordEditing { background: #e6a23c; }
|
||||||
|
.formList p > .recordSubmitted { background: #32a76f; }
|
||||||
|
.formList p > .recordDraft { background: #929aa7; }
|
||||||
|
.formList p > span { margin-left: 4px; padding-left: 6px; border-left: 1px solid #dfe2e8; }
|
||||||
|
.formList time { margin-left: auto; }
|
||||||
|
.noForms { padding: 35px 10px; color: #a1a7b2; font-size: 8px; text-align: center; }
|
||||||
|
.sidebarFooter { padding: 10px 12px 13px; border-top: 1px solid #e9ebef; }
|
||||||
|
.sidebarFooter button { width: 100%; display: block; padding: 8px; border: 0; border-radius: 6px; background: none; color: #717b8c; cursor: pointer; text-align: left; font-size: 8px; }
|
||||||
|
.sidebarFooter button:hover { background: #f3f5f8; color: var(--blue); }
|
||||||
|
.sidebarFooter p { margin: 9px 8px 0; color: #939ba8; font-size: 7px; line-height: 1.5; }
|
||||||
|
.sidebarFooter p span { color: #b0b6bf; }
|
||||||
|
|
||||||
|
.main { width: min(1120px,100%); margin: 0 auto; padding: 25px 32px 80px; }
|
||||||
|
.breadcrumb { display: flex; align-items: center; gap: 7px; margin-bottom: 19px; color: #a1a8b4; font-size: 7px; }
|
||||||
|
.breadcrumb a { color: #7b8aa3; text-decoration: none; }
|
||||||
|
.breadcrumb strong { color: #657087; font-weight: 600; }
|
||||||
|
.formHeader { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 21px; }
|
||||||
|
.titleGroup { display: grid; grid-template-columns: auto minmax(0,1fr); align-items: center; column-gap: 9px; }
|
||||||
|
.titleGroup input { width: min(650px,65vw); padding: 0; border: 0; outline: 0; background: transparent; color: #1f293b; font-size: 24px; font-weight: 750; letter-spacing: -.6px; }
|
||||||
|
.titleGroup p { grid-column: 1/-1; margin: 8px 0 0; color: #9199a7; font-size: 8px; }
|
||||||
|
.formStatus { padding: 5px 8px; border-radius: 5px; font-size: 7px; font-weight: 700; }
|
||||||
|
.formStatusEditing { background: #fff2dd; color: #c67d24; }
|
||||||
|
.formStatusSubmitted { background: #e7f7ef; color: #27875b; }
|
||||||
|
.formStatusDraft { background: #eceef2; color: #757e8d; }
|
||||||
|
.headerMenu { display: flex; gap: 6px; }
|
||||||
|
.headerMenu button { height: 30px; padding: 0 10px; border: 1px solid #e0e3e9; border-radius: 6px; background: #fff; color: #778194; cursor: pointer; font-size: 8px; }
|
||||||
|
.summary { display: grid; grid-template-columns: repeat(4,1fr); overflow: hidden; margin-bottom: 17px; border: 1px solid var(--line); border-radius: 11px; background: #fff; box-shadow: 0 6px 22px rgba(34,47,75,.035); }
|
||||||
|
.summary > div { min-height: 78px; padding: 16px 20px; border-right: 1px solid #eef0f4; }
|
||||||
|
.summary > div:last-child { border-right: 0; }
|
||||||
|
.summary span { display: block; margin-bottom: 6px; color: #8f98a8; font-size: 8px; }
|
||||||
|
.summary strong { color: #29354a; font-family: Georgia,serif; font-size: 20px; font-weight: 500; }
|
||||||
|
.summary strong em { color: #a4aab4; font-size: 10px; font-style: normal; }
|
||||||
|
.summary small { float: right; margin-top: 8px; color: #a5abb6; font-size: 7px; }
|
||||||
|
.warning { min-height: 48px; display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px 13px; border: 1px solid #f0d59d; border-radius: 8px; background: #fff9ec; color: #9b701f; }
|
||||||
|
.warning > span { width: 23px; height: 23px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #e9ad3e; color: #fff; font-weight: 800; }
|
||||||
|
.warning p { flex: 1; margin: 0; font-size: 8px; line-height: 1.6; }
|
||||||
|
.warning p strong { display: block; }
|
||||||
|
.warning button { border: 0; background: none; color: inherit; cursor: pointer; }
|
||||||
|
.sectionBar { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin: 26px 0 12px; }
|
||||||
|
.sectionBar p { margin: 0 0 4px; color: var(--blue); font-size: 6px; font-weight: 800; letter-spacing: 1.5px; }
|
||||||
|
.sectionBar h2 { display: inline; margin: 0; font-size: 14px; }
|
||||||
|
.sectionBar div > span { margin-left: 9px; color: #969eab; font-size: 8px; }
|
||||||
|
.sectionBar > button { height: 32px; padding: 0 13px; border: 1px solid #cad8f5; border-radius: 7px; background: #fff; color: #3d6bd4; cursor: pointer; font-size: 8px; font-weight: 700; }
|
||||||
|
|
||||||
|
.itemList { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.itemCard { scroll-margin-top: 78px; overflow: hidden; border: 1px solid #e1e5ec; border-radius: 11px; background: #fff; box-shadow: 0 6px 22px rgba(38,50,75,.035); }
|
||||||
|
.itemHeader { min-height: 59px; display: flex; align-items: center; gap: 10px; padding: 9px 13px; border-bottom: 1px solid #edf0f4; background: #fcfcfd; }
|
||||||
|
.dragHandle { color: #c2c7cf; cursor: grab; font-size: 11px; letter-spacing: -3px; }
|
||||||
|
.itemNumber { width: 30px; height: 30px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 7px; background: #e9effe; color: #456dcb; font-family: Georgia,serif; font-size: 10px; }
|
||||||
|
.itemHeader > div:nth-of-type(1) { min-width: 0; flex: 1; }
|
||||||
|
.itemHeader strong { display: block; overflow: hidden; color: #334056; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.itemHeader small { display: block; margin-top: 4px; color: #9ba3b0; font-size: 7px; }
|
||||||
|
.itemStatus { padding: 4px 7px; border-radius: 10px; font-size: 7px; }
|
||||||
|
.itemStatusEmpty { background: #eff1f4; color: #7d8593; }
|
||||||
|
.itemStatusDoing { background: #eaf1ff; color: #3d6cd5; }
|
||||||
|
.itemStatusReview { background: #fff1db; color: #c57d22; }
|
||||||
|
.itemStatusDone { background: #e5f7ee; color: #2c9463; }
|
||||||
|
.itemActions { display: flex; align-items: center; gap: 2px; margin-left: 4px; }
|
||||||
|
.itemActions button { height: 25px; padding: 0 7px; border: 0; border-radius: 5px; background: transparent; color: #8992a1; cursor: pointer; font-size: 7px; }
|
||||||
|
.itemActions button:hover:not(:disabled) { background: #eef2f8; color: #4369c2; }
|
||||||
|
.itemActions button:disabled { opacity: .3; cursor: not-allowed; }
|
||||||
|
.itemActions .deleteAction:hover { background: #fff0f0; color: #d65353; }
|
||||||
|
.itemBody { padding: 20px; }
|
||||||
|
.fieldsGrid { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: 13px; }
|
||||||
|
.fieldsGrid label { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.fieldsGrid label > span { color: #5f697a; font-size: 8px; font-weight: 650; }
|
||||||
|
.fieldsGrid label > span b { color: #df5656; }
|
||||||
|
.fieldsGrid input, .fieldsGrid select, .fieldsGrid textarea { width: 100%; border: 1px solid #dfe3ea; border-radius: 7px; outline: 0; background: #fff; color: #465267; font-size: 9px; }
|
||||||
|
.fieldsGrid input, .fieldsGrid select { height: 34px; padding: 0 10px; }
|
||||||
|
.fieldsGrid textarea { height: 67px; resize: vertical; padding: 9px 10px; line-height: 1.6; }
|
||||||
|
.fieldsGrid input:focus, .fieldsGrid select:focus, .fieldsGrid textarea:focus { border-color: #93b0ed; box-shadow: 0 0 0 3px rgba(61,111,232,.07); }
|
||||||
|
.wideField { grid-column: span 2; }
|
||||||
|
.descriptionField { grid-column: span 4; }
|
||||||
|
.attachmentSection { margin-top: 20px; padding-top: 17px; border-top: 1px solid #eef0f3; }
|
||||||
|
.attachmentHeading { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 12px; }
|
||||||
|
.attachmentHeading > div { display: flex; align-items: center; gap: 9px; }
|
||||||
|
.imageBadge, .fileBadge { width: 29px; height: 29px; display: grid; place-items: center; border-radius: 7px; background: #e9f0ff; color: #4270d6; font-size: 11px; }
|
||||||
|
.fileBadge { background: #efeaff; color: #7156bf; }
|
||||||
|
.attachmentHeading p { display: flex; flex-direction: column; gap: 3px; margin: 0; }
|
||||||
|
.attachmentHeading strong { color: #4d586a; font-size: 8px; }
|
||||||
|
.attachmentHeading small { color: #a1a8b3; font-size: 7px; }
|
||||||
|
.uploadButton { height: 29px; display: flex; align-items: center; padding: 0 10px; border: 1px solid #dbe1eb; border-radius: 6px; background: #fff; color: #526482; cursor: pointer; font-size: 7px; }
|
||||||
|
.uploadButton:hover { border-color: #9eb4e3; color: var(--blue); }
|
||||||
|
.uploadButton input, .emptyImage input, .emptyFile input { display: none; }
|
||||||
|
.imageGrid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 9px; }
|
||||||
|
.imageGrid figure { min-width: 0; overflow: hidden; margin: 0; border: 1px solid #e1e5eb; border-radius: 8px; background: #fafbfc; }
|
||||||
|
.imagePreview { position: relative; width: 100%; height: 112px; display: block; overflow: hidden; padding: 0; border: 0; background: #e9edf3; cursor: zoom-in; }
|
||||||
|
.imagePreview img { width: 100%; height: 100%; display: block; object-fit: cover; transition: transform .2s; }
|
||||||
|
.imagePreview > span { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(22,31,48,.52); color: #fff; font-size: 8px; opacity: 0; transition: opacity .2s; }
|
||||||
|
.imagePreview:hover img { transform: scale(1.035); }
|
||||||
|
.imagePreview:hover > span { opacity: 1; }
|
||||||
|
.imageGrid figcaption { min-height: 42px; display: flex; align-items: center; gap: 6px; padding: 7px 8px; }
|
||||||
|
.imageGrid figcaption p { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 2px; margin: 0; }
|
||||||
|
.imageGrid figcaption strong { overflow: hidden; color: #596477; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.imageGrid figcaption small { color: #a0a7b2; font-size: 6px; }
|
||||||
|
.imageGrid figcaption button { width: 21px; height: 21px; border: 0; border-radius: 4px; background: none; color: #a6acb6; cursor: pointer; }
|
||||||
|
.imageGrid figcaption button:hover { background: #fff0f0; color: #d75c5c; }
|
||||||
|
.emptyImage { min-height: 155px; display: flex; align-items: center; justify-content: center; flex-direction: column; border: 1px dashed #cfd6e1; border-radius: 8px; background: #fafbfc; color: #9aa3b1; cursor: pointer; }
|
||||||
|
.emptyImage:hover { border-color: #8eabe8; background: #f7f9ff; }
|
||||||
|
.emptyImage > span { width: 31px; height: 31px; display: grid; place-items: center; margin-bottom: 7px; border-radius: 50%; background: #eaf0fd; color: #557bd3; font-size: 14px; }
|
||||||
|
.emptyImage strong { color: #687589; font-size: 8px; }
|
||||||
|
.emptyImage small { margin-top: 4px; font-size: 6px; }
|
||||||
|
.fileList { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.fileList > div { min-height: 48px; display: flex; align-items: center; gap: 10px; padding: 7px 9px; border: 1px solid #e4e7ed; border-radius: 7px; background: #fbfbfc; }
|
||||||
|
.fileType { width: 33px; height: 33px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 6px; background: #eeeafd; color: #6d57b7; font-size: 6px; font-weight: 800; }
|
||||||
|
.fileList p { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 3px; margin: 0; }
|
||||||
|
.fileList strong { overflow: hidden; color: #536074; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.fileList small { color: #a0a7b2; font-size: 6px; }
|
||||||
|
.fileList a, .fileList > div > button { padding: 4px 7px; border: 0; border-radius: 4px; background: transparent; color: #5272bd; cursor: pointer; font-size: 7px; text-decoration: none; }
|
||||||
|
.fileList > div > button:last-child { width: 23px; color: #a2a9b4; font-size: 11px; }
|
||||||
|
.fileList > div > button:last-child:hover { background: #fff0f0; color: #d75c5c; }
|
||||||
|
.emptyFile { min-height: 46px; display: grid; place-items: center; border: 1px dashed #d2d7e0; border-radius: 7px; color: #8e99a9; cursor: pointer; font-size: 8px; }
|
||||||
|
.emptyFile:hover { border-color: #9bafe0; background: #f7f9ff; color: #4f70bd; }
|
||||||
|
.emptyItems { min-height: 260px; display: flex; align-items: center; justify-content: center; flex-direction: column; border: 1px dashed #cfd6e2; border-radius: 11px; background: #fff; color: #98a1af; }
|
||||||
|
.emptyItems > span { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 50%; background: #edf2fb; color: #5d7dc4; font-size: 18px; }
|
||||||
|
.emptyItems h3 { margin: 12px 0 3px; color: #556174; font-size: 11px; }
|
||||||
|
.emptyItems p { margin: 0 0 13px; font-size: 8px; }
|
||||||
|
.emptyItems button { height: 31px; padding: 0 12px; border: 0; border-radius: 6px; background: var(--blue); color: #fff; cursor: pointer; font-size: 8px; }
|
||||||
|
.addItemBottom { width: 100%; min-height: 65px; display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 12px; border: 1px dashed #bdcbe5; border-radius: 10px; background: rgba(255,255,255,.55); color: #62718a; cursor: pointer; }
|
||||||
|
.addItemBottom:hover { border-color: #7595db; background: #f8faff; }
|
||||||
|
.addItemBottom > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; background: #e5edfe; color: #4971ca; }
|
||||||
|
.addItemBottom strong { font-size: 8px; }
|
||||||
|
.addItemBottom small { color: #9fa7b3; font-size: 7px; font-weight: 400; }
|
||||||
|
.formFooter { min-height: 70px; display: flex; align-items: center; justify-content: space-between; margin-top: 22px; padding-top: 18px; border-top: 1px solid #dfe3ea; }
|
||||||
|
.formFooter p { display: flex; align-items: center; gap: 6px; margin: 0; color: #939baa; font-size: 7px; }
|
||||||
|
.formFooter > div { display: flex; gap: 8px; }
|
||||||
|
.formFooter button { height: 34px; padding: 0 14px; border: 1px solid #dce1e9; border-radius: 7px; background: #fff; color: #687487; cursor: pointer; font-size: 8px; }
|
||||||
|
.formFooter .submitButton { border-color: var(--blue); background: var(--blue); color: #fff; }
|
||||||
|
.lightbox { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 35px; background: rgba(12,19,31,.72); backdrop-filter: blur(7px); }
|
||||||
|
.lightbox > div { width: min(980px,95vw); max-height: 88vh; overflow: hidden; border-radius: 12px; background: #fff; box-shadow: 0 30px 90px rgba(3,8,18,.45); }
|
||||||
|
.lightbox header { height: 52px; display: flex; align-items: center; justify-content: space-between; padding: 0 15px; }
|
||||||
|
.lightbox header p { display: flex; flex-direction: column; gap: 3px; margin: 0; }
|
||||||
|
.lightbox header strong { font-size: 9px; }
|
||||||
|
.lightbox header small { color: #9aa2ae; font-size: 7px; }
|
||||||
|
.lightbox header button { width: 30px; height: 30px; border: 0; border-radius: 50%; background: #f1f2f5; color: #717a89; cursor: pointer; }
|
||||||
|
.lightbox img { width: 100%; max-height: calc(88vh - 52px); display: block; object-fit: contain; background: #101522; }
|
||||||
|
.toast { position: fixed; left: 50%; bottom: 25px; z-index: 70; padding: 10px 17px; border-radius: 22px; background: rgba(29,38,55,.96); color: #fff; box-shadow: 0 12px 30px rgba(22,30,44,.25); font-size: 8px; animation: toastIn .22s ease both; }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.topbar nav { display: none; }
|
||||||
|
.workspace { grid-template-columns: 220px minmax(0,1fr); }
|
||||||
|
.sidebar { width: 220px; }
|
||||||
|
.main { padding: 23px 20px 65px; }
|
||||||
|
.fieldsGrid { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||||
|
.wideField { grid-column: span 2; }
|
||||||
|
.descriptionField { grid-column: span 2; }
|
||||||
|
.imageGrid { grid-template-columns: repeat(3,minmax(0,1fr)); }
|
||||||
|
.itemActions button:nth-child(-n+2) { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar { padding: 0 13px; }
|
||||||
|
.saveState, .headerActions > button:first-child, .headerActions > span { display: none; }
|
||||||
|
.headerActions { margin-left: auto; }
|
||||||
|
.workspace { display: block; }
|
||||||
|
.sidebar { position: relative; top: auto; width: 100%; height: auto; border-right: 0; border-bottom: 1px solid #e2e6ed; }
|
||||||
|
.sidebarTitle { height: 58px; }
|
||||||
|
.formList { max-height: 205px; }
|
||||||
|
.sidebarFooter { display: none; }
|
||||||
|
.main { width: 100%; padding: 20px 14px 55px; }
|
||||||
|
.breadcrumb { display: none; }
|
||||||
|
.titleGroup { grid-template-columns: 1fr; }
|
||||||
|
.titleGroup input { width: 100%; margin-top: 8px; font-size: 20px; }
|
||||||
|
.titleGroup p { grid-column: 1; line-height: 1.6; }
|
||||||
|
.headerMenu { display: none; }
|
||||||
|
.summary { grid-template-columns: repeat(2,1fr); }
|
||||||
|
.summary > div:nth-child(2) { border-right: 0; }
|
||||||
|
.summary > div:nth-child(-n+2) { border-bottom: 1px solid #eef0f4; }
|
||||||
|
.sectionBar div > span { display: none; }
|
||||||
|
.itemHeader { flex-wrap: wrap; }
|
||||||
|
.itemHeader > div:nth-of-type(1) { min-width: 50%; }
|
||||||
|
.itemStatus { margin-left: auto; }
|
||||||
|
.itemActions { width: 100%; justify-content: flex-end; padding-top: 6px; border-top: 1px solid #eef0f3; }
|
||||||
|
.itemBody { padding: 15px; }
|
||||||
|
.fieldsGrid { grid-template-columns: 1fr; }
|
||||||
|
.wideField, .descriptionField { grid-column: 1; }
|
||||||
|
.imageGrid { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||||
|
.attachmentHeading small { display: none; }
|
||||||
|
.addItemBottom small { display: none; }
|
||||||
|
.formFooter { align-items: flex-start; gap: 15px; flex-direction: column; }
|
||||||
|
.formFooter > div { width: 100%; }
|
||||||
|
.formFooter button { flex: 1; }
|
||||||
|
.lightbox { padding: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
.imageGrid { grid-template-columns: 1fr; }
|
||||||
|
.imagePreview { height: 180px; }
|
||||||
|
}
|
||||||
319
app/repeatable-form/page.tsx
Normal file
319
app/repeatable-form/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import seedData from "../data/repeatable-forms.json";
|
||||||
|
import styles from "./RepeatableForm.module.css";
|
||||||
|
|
||||||
|
type ImageAttachment = { id: string; name: string; size: string; url: string };
|
||||||
|
type FileAttachment = { id: string; name: string; size: string; type: string; url: string };
|
||||||
|
type FormItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
owner: string;
|
||||||
|
status: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
dueDate: string;
|
||||||
|
description: string;
|
||||||
|
images: ImageAttachment[];
|
||||||
|
files: FileAttachment[];
|
||||||
|
};
|
||||||
|
type FormRecord = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
tags: string[];
|
||||||
|
items: FormItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialForms = seedData.forms as FormRecord[];
|
||||||
|
const recordStatusStyles: Record<string, string> = { "编辑中": "recordEditing", "已提交": "recordSubmitted", "草稿": "recordDraft" };
|
||||||
|
const formStatusStyles: Record<string, string> = { "编辑中": "formStatusEditing", "已提交": "formStatusSubmitted", "草稿": "formStatusDraft" };
|
||||||
|
const itemStatusStyles: Record<string, string> = { "待填写": "itemStatusEmpty", "进行中": "itemStatusDoing", "待确认": "itemStatusReview", "已完成": "itemStatusDone" };
|
||||||
|
|
||||||
|
export default function RepeatableFormPage() {
|
||||||
|
const [forms, setForms] = useState<FormRecord[]>(initialForms);
|
||||||
|
const [activeId, setActiveId] = useState(initialForms[0].id);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("全部");
|
||||||
|
const [collapsed, setCollapsed] = useState<string[]>([]);
|
||||||
|
const [lightbox, setLightbox] = useState<ImageAttachment | null>(null);
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
const [saved, setSaved] = useState(true);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const [storageWarning, setStorageWarning] = useState(false);
|
||||||
|
const saveTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const cached = window.localStorage.getItem("orbit-repeatable-forms");
|
||||||
|
const cachedActive = window.localStorage.getItem("orbit-repeatable-active");
|
||||||
|
if (cached) setForms(JSON.parse(cached));
|
||||||
|
if (cachedActive) setActiveId(cachedActive);
|
||||||
|
} catch {
|
||||||
|
window.localStorage.removeItem("orbit-repeatable-forms");
|
||||||
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
|
setSaved(false);
|
||||||
|
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem("orbit-repeatable-forms", JSON.stringify(forms));
|
||||||
|
window.localStorage.setItem("orbit-repeatable-active", activeId);
|
||||||
|
setSaved(true);
|
||||||
|
setStorageWarning(false);
|
||||||
|
} catch {
|
||||||
|
setStorageWarning(true);
|
||||||
|
}
|
||||||
|
}, 550);
|
||||||
|
return () => { if (saveTimer.current) window.clearTimeout(saveTimer.current); };
|
||||||
|
}, [forms, activeId, hydrated]);
|
||||||
|
|
||||||
|
const activeForm = forms.find((form) => form.id === activeId) ?? forms[0];
|
||||||
|
const visibleForms = useMemo(() => forms.filter((form) => {
|
||||||
|
const matchesQuery = `${form.title}${form.code}${form.tags.join("")}`.toLowerCase().includes(query.toLowerCase());
|
||||||
|
return matchesQuery && (statusFilter === "全部" || form.status === statusFilter);
|
||||||
|
}), [forms, query, statusFilter]);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const items = activeForm?.items ?? [];
|
||||||
|
return {
|
||||||
|
completed: items.filter((item) => item.status === "已完成").length,
|
||||||
|
images: items.reduce((sum, item) => sum + item.images.length, 0),
|
||||||
|
files: items.reduce((sum, item) => sum + item.files.length, 0),
|
||||||
|
};
|
||||||
|
}, [activeForm]);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateForm = (patch: Partial<FormRecord>) => setForms((current) => current.map((form) => form.id === activeId ? { ...form, ...patch, updatedAt: "刚刚" } : form));
|
||||||
|
|
||||||
|
const updateItem = (itemId: string, patch: Partial<FormItem>) => setForms((current) => current.map((form) => form.id === activeId ? {
|
||||||
|
...form,
|
||||||
|
updatedAt: "刚刚",
|
||||||
|
items: form.items.map((item) => item.id === itemId ? { ...item, ...patch } : item),
|
||||||
|
} : form));
|
||||||
|
|
||||||
|
const createForm = () => {
|
||||||
|
const id = `FORM-${Date.now()}`;
|
||||||
|
const record: FormRecord = { id, title: "未命名资料表单", code: `DRAFT-${String(Date.now()).slice(-6)}`, status: "草稿", updatedAt: "刚刚", createdBy: "林知夏", tags: ["新表单"], items: [emptyItem()] };
|
||||||
|
setForms((current) => [record, ...current]);
|
||||||
|
setActiveId(id);
|
||||||
|
notify("已创建新表单");
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicateForm = () => {
|
||||||
|
if (!activeForm) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const duplicated: FormRecord = {
|
||||||
|
...activeForm,
|
||||||
|
id: `FORM-${now}`,
|
||||||
|
title: `${activeForm.title} - 副本`,
|
||||||
|
status: "草稿",
|
||||||
|
updatedAt: "刚刚",
|
||||||
|
items: activeForm.items.map((item, index) => ({ ...item, id: `ITEM-${now}-${index}` })),
|
||||||
|
};
|
||||||
|
setForms((current) => [duplicated, ...current]);
|
||||||
|
setActiveId(duplicated.id);
|
||||||
|
notify("表单副本已创建");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeForm = () => {
|
||||||
|
if (!activeForm || forms.length === 1) return notify("至少需要保留一份表单");
|
||||||
|
const next = forms.filter((form) => form.id !== activeForm.id);
|
||||||
|
setForms(next);
|
||||||
|
setActiveId(next[0].id);
|
||||||
|
notify("表单已删除");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
const item = emptyItem();
|
||||||
|
updateForm({ items: [...activeForm.items, item] });
|
||||||
|
window.setTimeout(() => document.getElementById(item.id)?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
|
||||||
|
notify("已添加一条表单项");
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicateItem = (item: FormItem) => {
|
||||||
|
const clone = { ...item, id: `ITEM-${Date.now()}`, title: `${item.title} - 副本`, images: [...item.images], files: [...item.files] };
|
||||||
|
const index = activeForm.items.findIndex((current) => current.id === item.id);
|
||||||
|
const next = [...activeForm.items];
|
||||||
|
next.splice(index + 1, 0, clone);
|
||||||
|
updateForm({ items: next });
|
||||||
|
notify("表单项已复制");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (itemId: string) => {
|
||||||
|
updateForm({ items: activeForm.items.filter((item) => item.id !== itemId) });
|
||||||
|
notify("表单项已删除");
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveItem = (itemId: string, direction: -1 | 1) => {
|
||||||
|
const index = activeForm.items.findIndex((item) => item.id === itemId);
|
||||||
|
const target = index + direction;
|
||||||
|
if (index < 0 || target < 0 || target >= activeForm.items.length) return;
|
||||||
|
const next = [...activeForm.items];
|
||||||
|
[next[index], next[target]] = [next[target], next[index]];
|
||||||
|
updateForm({ items: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachments = async (itemId: string, files: FileList | null, kind: "images" | "files") => {
|
||||||
|
if (!files?.length) return;
|
||||||
|
const item = activeForm.items.find((current) => current.id === itemId);
|
||||||
|
if (!item) return;
|
||||||
|
const limit = kind === "images" ? 5 : 6;
|
||||||
|
const currentCount = item[kind].length;
|
||||||
|
const incoming = Array.from(files).slice(0, Math.max(0, limit - currentCount));
|
||||||
|
if (incoming.length === 0) return notify(`每条最多添加 ${limit} 个${kind === "images" ? "图片" : "文件"}`);
|
||||||
|
|
||||||
|
const maxBytes = kind === "images" ? 1_500_000 : 1_000_000;
|
||||||
|
const allowed = incoming.filter((file) => file.size <= maxBytes && (kind === "files" || file.type.startsWith("image/")));
|
||||||
|
if (allowed.length !== incoming.length) notify(`已跳过超过限制的${kind === "images" ? "图片(1.5MB)" : "文件(1MB)"}`);
|
||||||
|
const encoded = await Promise.all(allowed.map(async (file, index) => {
|
||||||
|
const url = await readAsDataUrl(file);
|
||||||
|
if (kind === "images") return { id: `IMG-${Date.now()}-${index}`, name: file.name, size: formatSize(file.size), url } as ImageAttachment;
|
||||||
|
const extension = file.name.split(".").pop()?.toUpperCase() || "FILE";
|
||||||
|
return { id: `FILE-${Date.now()}-${index}`, name: file.name, size: formatSize(file.size), type: extension, url } as FileAttachment;
|
||||||
|
}));
|
||||||
|
updateItem(itemId, kind === "images" ? { images: [...item.images, ...(encoded as ImageAttachment[])] } : { files: [...item.files, ...(encoded as FileAttachment[])] });
|
||||||
|
notify(`已添加 ${encoded.length} 个${kind === "images" ? "图片" : "文件"}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveNow = () => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem("orbit-repeatable-forms", JSON.stringify(forms));
|
||||||
|
setSaved(true);
|
||||||
|
setStorageWarning(false);
|
||||||
|
notify("整份表单已保存到当前浏览器");
|
||||||
|
} catch {
|
||||||
|
setStorageWarning(true);
|
||||||
|
notify("存储空间不足,请删除较大的附件后重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!activeForm) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.topbar}>
|
||||||
|
<Link href="/" className={styles.brand}><span>F</span><strong>FormStack</strong></Link>
|
||||||
|
<nav><Link href="/simple-list">列表模板</Link><Link className={styles.activeNav} href="/repeatable-form">可重复表单</Link><Link href="/markdown">文档编辑</Link></nav>
|
||||||
|
<div className={styles.saveState}><i className={saved ? styles.savedDot : ""} />{saved ? "所有更改已保存" : "正在自动保存…"}</div>
|
||||||
|
<div className={styles.headerActions}><button onClick={() => notify("预览模式为模板交互占位")}>预览</button><button className={styles.saveButton} onClick={saveNow}>保存表单</button><span>林</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.workspace}>
|
||||||
|
<aside className={styles.sidebar}>
|
||||||
|
<div className={styles.sidebarTitle}><div><p>FORM RECORDS</p><strong>表单列表</strong></div><button onClick={createForm}>+</button></div>
|
||||||
|
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索名称或编号" /></label>
|
||||||
|
<div className={styles.statusTabs}>{["全部", "编辑中", "已提交", "草稿"].map((value) => <button key={value} className={statusFilter === value ? styles.statusTabActive : ""} onClick={() => setStatusFilter(value)}>{value}</button>)}</div>
|
||||||
|
<div className={styles.formList}>
|
||||||
|
{visibleForms.map((form) => <button key={form.id} className={form.id === activeId ? styles.formActive : ""} onClick={() => setActiveId(form.id)}>
|
||||||
|
<span className={styles.formIcon}>▤</span><div><strong>{form.title}</strong><small>{form.code}</small><p><i className={styles[recordStatusStyles[form.status]]} />{form.status}<span>{form.items.length} 项</span><time>{form.updatedAt}</time></p></div>
|
||||||
|
</button>)}
|
||||||
|
{visibleForms.length === 0 && <div className={styles.noForms}>没有匹配表单</div>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.sidebarFooter}><button onClick={duplicateForm}>▣ 复制当前表单</button><button onClick={removeForm}>⌫ 删除当前表单</button><p>本地演示模式<br /><span>数据仅保存在当前设备</span></p></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<div className={styles.breadcrumb}><Link href="/">模板首页</Link><span>/</span><span>可重复表单</span><span>/</span><strong>{activeForm.code}</strong></div>
|
||||||
|
<section className={styles.formHeader}>
|
||||||
|
<div className={styles.titleGroup}><span className={`${styles.formStatus} ${styles[formStatusStyles[activeForm.status]]}`}>{activeForm.status}</span><input value={activeForm.title} onChange={(event) => updateForm({ title: event.target.value })} aria-label="表单名称" /><p>编号 {activeForm.code} · 创建人 {activeForm.createdBy} · 更新于 {activeForm.updatedAt}</p></div>
|
||||||
|
<div className={styles.headerMenu}><button onClick={duplicateForm}>复制</button><button onClick={() => notify("更多操作:导出 JSON、打印、归档")}>•••</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.summary}>
|
||||||
|
<div><span>表单项</span><strong>{activeForm.items.length}</strong><small>可继续添加</small></div>
|
||||||
|
<div><span>已完成</span><strong>{totals.completed}<em> / {activeForm.items.length}</em></strong><small>{activeForm.items.length ? Math.round(totals.completed / activeForm.items.length * 100) : 0}% 完成</small></div>
|
||||||
|
<div><span>图片附件</span><strong>{totals.images}</strong><small>支持预览</small></div>
|
||||||
|
<div><span>文档附件</span><strong>{totals.files}</strong><small>支持下载</small></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{storageWarning && <div className={styles.warning}><span>!</span><p><strong>浏览器存储空间不足</strong>请删除较大的图片或文件。此纯前端模板会将新附件保存为本地数据。</p><button onClick={() => setStorageWarning(false)}>×</button></div>}
|
||||||
|
|
||||||
|
<div className={styles.sectionBar}><div><p>REPEATABLE ITEMS</p><h2>表单明细</h2><span>每条明细可独立添加图片和文件附件。</span></div><button onClick={addItem}>+ 添加表单项</button></div>
|
||||||
|
|
||||||
|
<div className={styles.itemList}>
|
||||||
|
{activeForm.items.map((item, index) => {
|
||||||
|
const isCollapsed = collapsed.includes(item.id);
|
||||||
|
return <article className={styles.itemCard} id={item.id} key={item.id}>
|
||||||
|
<header className={styles.itemHeader}>
|
||||||
|
<span className={styles.dragHandle}>⋮⋮</span><span className={styles.itemNumber}>{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<div><strong>{item.title || "未命名表单项"}</strong><small>{item.category} · {item.images.length} 张图片 · {item.files.length} 个文件</small></div>
|
||||||
|
<span className={`${styles.itemStatus} ${styles[itemStatusStyles[item.status]]}`}>{item.status}</span>
|
||||||
|
<div className={styles.itemActions}><button disabled={index === 0} onClick={() => moveItem(item.id, -1)}>↑</button><button disabled={index === activeForm.items.length - 1} onClick={() => moveItem(item.id, 1)}>↓</button><button onClick={() => duplicateItem(item)}>复制</button><button onClick={() => setCollapsed((current) => isCollapsed ? current.filter((id) => id !== item.id) : [...current, item.id])}>{isCollapsed ? "展开" : "收起"}</button><button className={styles.deleteAction} onClick={() => removeItem(item.id)}>删除</button></div>
|
||||||
|
</header>
|
||||||
|
{!isCollapsed && <div className={styles.itemBody}>
|
||||||
|
<div className={styles.fieldsGrid}>
|
||||||
|
<label className={styles.wideField}><span>明细名称 <b>*</b></span><input value={item.title} onChange={(event) => updateItem(item.id, { title: event.target.value })} placeholder="请输入明细名称" /></label>
|
||||||
|
<label><span>分类</span><select value={item.category} onChange={(event) => updateItem(item.id, { category: event.target.value })}><option>页面交付</option><option>说明文档</option><option>设计素材</option><option>巡检项目</option><option>采购物料</option><option>其他</option></select></label>
|
||||||
|
<label><span>负责人</span><input value={item.owner} onChange={(event) => updateItem(item.id, { owner: event.target.value })} /></label>
|
||||||
|
<label><span>状态</span><select value={item.status} onChange={(event) => updateItem(item.id, { status: event.target.value })}><option>待填写</option><option>进行中</option><option>待确认</option><option>已完成</option></select></label>
|
||||||
|
<label><span>数量</span><input type="number" min="0" value={item.quantity} onChange={(event) => updateItem(item.id, { quantity: Number(event.target.value) })} /></label>
|
||||||
|
<label><span>单位</span><input value={item.unit} onChange={(event) => updateItem(item.id, { unit: event.target.value })} /></label>
|
||||||
|
<label><span>计划日期</span><input type="date" value={item.dueDate} onChange={(event) => updateItem(item.id, { dueDate: event.target.value })} /></label>
|
||||||
|
<label className={styles.descriptionField}><span>说明</span><textarea value={item.description} onChange={(event) => updateItem(item.id, { description: event.target.value })} placeholder="补充本条明细的内容、规格或验收要求" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.attachmentSection}>
|
||||||
|
<div className={styles.attachmentHeading}><div><span className={styles.imageBadge}>▧</span><p><strong>图片列表</strong><small>JPG、PNG、WEBP · 新上传单张不超过 1.5MB · 最多 5 张</small></p></div><label className={styles.uploadButton}>+ 添加图片<input type="file" accept="image/*" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "images"); event.currentTarget.value = ""; }} /></label></div>
|
||||||
|
<div className={styles.imageGrid}>
|
||||||
|
{item.images.map((image) => <figure key={image.id}><button className={styles.imagePreview} onClick={() => setLightbox(image)}><img src={image.url} alt={image.name} /><span>预览</span></button><figcaption><p><strong>{image.name}</strong><small>{image.size}</small></p><button aria-label={`删除 ${image.name}`} onClick={() => updateItem(item.id, { images: item.images.filter((current) => current.id !== image.id) })}>×</button></figcaption></figure>)}
|
||||||
|
{item.images.length === 0 && <label className={styles.emptyImage}><span>+</span><strong>添加现场或成果图片</strong><small>支持一次选择多张</small><input type="file" accept="image/*" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "images"); event.currentTarget.value = ""; }} /></label>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.attachmentSection}>
|
||||||
|
<div className={styles.attachmentHeading}><div><span className={styles.fileBadge}>⌑</span><p><strong>文件列表</strong><small>文档、表格、压缩包等 · 新上传单个不超过 1MB · 最多 6 个</small></p></div><label className={styles.uploadButton}>+ 添加文件<input type="file" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "files"); event.currentTarget.value = ""; }} /></label></div>
|
||||||
|
<div className={styles.fileList}>
|
||||||
|
{item.files.map((file) => <div key={file.id}><span className={styles.fileType}>{file.type}</span><p><strong>{file.name}</strong><small>{file.size} · 添加于刚刚</small></p>{file.url ? <a href={file.url} download={file.name}>下载</a> : <button onClick={() => notify("这是 JSON 示例文件,仅展示文件信息")}>查看</button>}<button aria-label={`删除 ${file.name}`} onClick={() => updateItem(item.id, { files: item.files.filter((current) => current.id !== file.id) })}>×</button></div>)}
|
||||||
|
{item.files.length === 0 && <label className={styles.emptyFile}>+ 点击添加相关文件<input type="file" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "files"); event.currentTarget.value = ""; }} /></label>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
{activeForm.items.length === 0 && <div className={styles.emptyItems}><span>▤</span><h3>还没有表单项</h3><p>添加第一条明细,并为它上传图片或文件。</p><button onClick={addItem}>+ 添加第一项</button></div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={styles.addItemBottom} onClick={addItem}><span>+</span><strong>继续添加一条表单项</strong><small>字段、图片和文件列表将独立保存</small></button>
|
||||||
|
<footer className={styles.formFooter}><p><i className={saved ? styles.savedDot : ""} />{saved ? `上次保存:${activeForm.updatedAt}` : "存在未保存更改"}</p><div><button onClick={() => updateForm({ status: "草稿" })}>保存为草稿</button><button className={styles.submitButton} onClick={() => { updateForm({ status: "已提交" }); saveNow(); notify("表单已提交(演示)"); }}>提交表单</button></div></footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightbox && <div className={styles.lightbox} onMouseDown={() => setLightbox(null)}><div onMouseDown={(event) => event.stopPropagation()}><header><p><strong>{lightbox.name}</strong><small>{lightbox.size}</small></p><button onClick={() => setLightbox(null)}>×</button></header><img src={lightbox.url} alt={lightbox.name} /></div></div>}
|
||||||
|
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyItem(): FormItem {
|
||||||
|
return { id: `ITEM-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, title: "", category: "页面交付", owner: "林知夏", status: "待填写", quantity: 1, unit: "项", dueDate: new Date().toISOString().slice(0, 10), description: "", images: [], files: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
3
app/simple-list/SimpleList.module.css
Normal file
3
app/simple-list/SimpleList.module.css
Normal file
File diff suppressed because one or more lines are too long
105
app/simple-list/page.tsx
Normal file
105
app/simple-list/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import data from "../data/content-templates.json";
|
||||||
|
import styles from "./SimpleList.module.css";
|
||||||
|
|
||||||
|
const categories = ["全部", "世界观", "制作资料", "角色", "音乐", "品牌", "运营"];
|
||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
"已发布": "statusPublished",
|
||||||
|
"评审中": "statusReview",
|
||||||
|
"草稿": "statusDraft",
|
||||||
|
"已归档": "statusArchived",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SimpleListPage() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [category, setCategory] = useState("全部");
|
||||||
|
const [status, setStatus] = useState("全部状态");
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [favorites, setFavorites] = useState<string[]>(data.listItems.filter((item) => item.favorite).map((item) => item.id));
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
const [compact, setCompact] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = window.localStorage.getItem("orbit-simple-list-favorites");
|
||||||
|
if (saved) {
|
||||||
|
try { setFavorites(JSON.parse(saved)); } catch { /* use JSON defaults */ }
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem("orbit-simple-list-favorites", JSON.stringify(favorites));
|
||||||
|
}, [favorites]);
|
||||||
|
|
||||||
|
const items = useMemo(() => data.listItems.filter((item) => {
|
||||||
|
const matchQuery = `${item.title}${item.description}${item.id}${item.tags.join("")}`.toLowerCase().includes(query.toLowerCase());
|
||||||
|
return matchQuery && (category === "全部" || item.category === category) && (status === "全部状态" || item.status === status);
|
||||||
|
}), [query, category, status]);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => setSelected(selected.length === items.length ? [] : items.map((item) => item.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.topbar}>
|
||||||
|
<Link className={styles.brand} href="/"><span>O</span><strong>Orbit Docs</strong></Link>
|
||||||
|
<nav><Link className={styles.active} href="/simple-list">简单列表</Link><Link href="/character">角色详情</Link><Link href="/markdown">Markdown 编辑器</Link></nav>
|
||||||
|
<div className={styles.topActions}><button onClick={() => notify("帮助中心为模板占位")}>?</button><span>林</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.content}>
|
||||||
|
<div className={styles.heading}>
|
||||||
|
<div><p>CONTENT LIBRARY</p><h1>内容资料库</h1><span>轻量、清晰的通用列表模板,适合文档、项目或数据记录。</span></div>
|
||||||
|
<button className={styles.createButton} onClick={() => notify("新建资料表单为模板占位")}>+ 新建资料</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.summary}>
|
||||||
|
<div><span className={styles.iconPurple}>▤</span><p><small>全部资料</small><strong>{data.listItems.length}</strong></p></div>
|
||||||
|
<div><span className={styles.iconGreen}>✓</span><p><small>已发布</small><strong>{data.listItems.filter((item) => item.status === "已发布").length}</strong></p></div>
|
||||||
|
<div><span className={styles.iconOrange}>◷</span><p><small>等待评审</small><strong>{data.listItems.filter((item) => item.status === "评审中").length}</strong></p></div>
|
||||||
|
<div><span className={styles.iconBlue}>★</span><p><small>我的收藏</small><strong>{favorites.length}</strong></p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.listPanel}>
|
||||||
|
<div className={styles.categoryTabs}>
|
||||||
|
{categories.map((item) => <button key={item} className={category === item ? styles.tabActive : ""} onClick={() => setCategory(item)}>{item}</button>)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、编号或标签" />{query && <button onClick={() => setQuery("")}>×</button>}</label>
|
||||||
|
<select value={status} onChange={(event) => setStatus(event.target.value)}><option>全部状态</option><option>已发布</option><option>评审中</option><option>草稿</option><option>已归档</option></select>
|
||||||
|
<button className={styles.filterButton} onClick={() => { setQuery(""); setCategory("全部"); setStatus("全部状态"); }}>清除筛选</button>
|
||||||
|
<div className={styles.viewToggle}><button className={!compact ? styles.viewActive : ""} onClick={() => setCompact(false)}>☷</button><button className={compact ? styles.viewActive : ""} onClick={() => setCompact(true)}>☰</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.length > 0 && <div className={styles.bulkBar}><strong>已选择 {selected.length} 项</strong><button onClick={() => notify("已添加到收藏")}>收藏</button><button onClick={() => notify("已移动到归档")}>归档</button><button onClick={() => setSelected([])}>取消选择</button></div>}
|
||||||
|
|
||||||
|
<div className={`${styles.list} ${compact ? styles.compact : ""}`}>
|
||||||
|
<div className={styles.listHeader}><input type="checkbox" checked={items.length > 0 && selected.length === items.length} onChange={toggleAll} aria-label="全选" /><span>资料名称</span><span>分类</span><span>状态</span><span>负责人</span><span>更新时间</span><span /></div>
|
||||||
|
{items.map((item) => {
|
||||||
|
const checked = selected.includes(item.id);
|
||||||
|
const favorite = favorites.includes(item.id);
|
||||||
|
return <article key={item.id} className={checked ? styles.rowSelected : ""}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={() => setSelected((current) => checked ? current.filter((id) => id !== item.id) : [...current, item.id])} aria-label={`选择 ${item.title}`} />
|
||||||
|
<button className={styles.titleCell} onClick={() => notify(`打开:${item.title}`)}><span>{item.category.slice(0, 1)}</span><div><strong>{item.title}</strong><p>{item.description}</p><small>{item.id} {item.tags.map((tag) => <i key={tag}>#{tag}</i>)}</small></div></button>
|
||||||
|
<span className={styles.category}>{item.category}</span>
|
||||||
|
<span className={`${styles.status} ${styles[statusStyles[item.status]]}`}><i />{item.status}</span>
|
||||||
|
<span className={styles.owner}><i>{item.initial}</i>{item.owner}</span>
|
||||||
|
<time>{item.updated}</time>
|
||||||
|
<div className={styles.rowActions}><button className={favorite ? styles.favorite : ""} onClick={() => setFavorites((current) => favorite ? current.filter((id) => id !== item.id) : [...current, item.id])}>★</button><button onClick={() => notify("更多操作:复制、移动、归档")}>•••</button></div>
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
{items.length === 0 && <div className={styles.empty}><span>⌕</span><strong>没有找到匹配资料</strong><p>调整关键词或筛选条件后再试试。</p><button onClick={() => { setQuery(""); setCategory("全部"); setStatus("全部状态"); }}>重置筛选</button></div>}
|
||||||
|
</div>
|
||||||
|
<footer className={styles.pagination}><span>显示 {items.length} 条,共 {data.listItems.length} 条</span><div><button disabled>←</button><button className={styles.pageActive}>1</button><button>2</button><button>→</button></div></footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
app/stargraph/StarGraph.module.css
Normal file
1
app/stargraph/StarGraph.module.css
Normal file
File diff suppressed because one or more lines are too long
10
app/stargraph/StarGraphHover.module.css
Normal file
10
app/stargraph/StarGraphHover.module.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.hoverCard{position:absolute;z-index:7;width:224px;padding:11px 12px;border:1px solid rgba(145,136,225,.32);border-radius:8px;background:rgba(12,15,34,.96);box-shadow:0 16px 38px rgba(0,0,0,.38),0 0 22px rgba(98,83,196,.12);color:#dfe4fa;pointer-events:none;backdrop-filter:blur(12px)}
|
||||||
|
.hoverHeader{display:flex;align-items:center;gap:8px;padding-bottom:8px;border-bottom:1px solid rgba(145,156,202,.14)}
|
||||||
|
.hoverHeader>i{width:9px;height:22px;flex:0 0 auto;border-radius:2px}
|
||||||
|
.hoverHeader>span{display:flex;min-width:0;flex:1;flex-direction:column;gap:2px}
|
||||||
|
.hoverHeader small{color:#7d87aa;font-size:8px}
|
||||||
|
.hoverHeader strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.hoverHeader em{padding:3px 5px;border-radius:4px;background:rgba(129,112,228,.13);color:#9b92db;font-size:7px;font-style:normal;white-space:nowrap}
|
||||||
|
.hoverCard p{margin:8px 0 0;color:#929bbd;font-size:9px;line-height:1.65}
|
||||||
|
.hoverTags{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
|
||||||
|
.hoverTags span{padding:3px 6px;border-radius:4px;background:rgba(115,103,192,.12);color:#888fb4;font-size:7px}
|
||||||
728
app/stargraph/page.tsx
Normal file
728
app/stargraph/page.tsx
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ChangeEvent,
|
||||||
|
PointerEvent as ReactPointerEvent,
|
||||||
|
WheelEvent as ReactWheelEvent,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import demoData from "../data/star-graph.json";
|
||||||
|
import styles from "./StarGraph.module.css";
|
||||||
|
import hoverStyles from "./StarGraphHover.module.css";
|
||||||
|
|
||||||
|
type GraphNode = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphEdge = {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphData = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
nodeTypes: { id: string; name: string; color: string }[];
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Interaction = {
|
||||||
|
mode: "node" | "pan";
|
||||||
|
nodeId?: string;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
baseX: number;
|
||||||
|
baseY: number;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "orbit-star-graph-v1";
|
||||||
|
const INITIAL_GRAPH = demoData as GraphData;
|
||||||
|
const RELATION_TYPES = ["角色关系", "组织关系", "地点关系", "剧情关系", "事件关系", "协作关系", "自定义"];
|
||||||
|
|
||||||
|
export default function StarGraphPage() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const importRef = useRef<HTMLInputElement>(null);
|
||||||
|
const interactionRef = useRef<Interaction | null>(null);
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
|
const [graph, setGraph] = useState<GraphData>(() => cloneGraph(INITIAL_GRAPH));
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 650, dpr: 1 });
|
||||||
|
const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 });
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>("project-starsea");
|
||||||
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
|
const [connectMode, setConnectMode] = useState(false);
|
||||||
|
const [connectFrom, setConnectFrom] = useState<string | null>(null);
|
||||||
|
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||||
|
const [hoverPoint, setHoverPoint] = useState({ x: 0, y: 0 });
|
||||||
|
const [showAddNode, setShowAddNode] = useState(false);
|
||||||
|
const [showAddEdge, setShowAddEdge] = useState(false);
|
||||||
|
const [addPoint, setAddPoint] = useState({ x: 0, y: 0 });
|
||||||
|
const [pendingEdge, setPendingEdge] = useState({ source: "", target: "" });
|
||||||
|
const [newNode, setNewNode] = useState({ label: "", type: "character", description: "", tags: "" });
|
||||||
|
const [newEdge, setNewEdge] = useState({ label: "关联", type: "自定义" });
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
|
||||||
|
const stars = useMemo(() => makeStars(180), []);
|
||||||
|
const selectedNode = graph.nodes.find((node) => node.id === selectedNodeId) ?? null;
|
||||||
|
const selectedEdge = graph.edges.find((edge) => edge.id === selectedEdgeId) ?? null;
|
||||||
|
const fromNode = graph.nodes.find((node) => node.id === connectFrom) ?? null;
|
||||||
|
const hoveredNode = graph.nodes.find((node) => node.id === hoveredNodeId) ?? null;
|
||||||
|
const visibleNodes = graph.nodes.filter((node) => {
|
||||||
|
const text = `${node.label} ${node.type} ${node.tags.join(" ")}`.toLowerCase();
|
||||||
|
return (typeFilter === "all" || node.type === typeFilter) && text.includes(query.trim().toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (isGraphData(parsed)) setGraph(parsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the bundled demo if local data cannot be read.
|
||||||
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(graph));
|
||||||
|
}, [graph, hydrated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const resize = () => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||||
|
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||||
|
setCanvasSize({ width: rect.width, height: rect.height, dpr });
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
const observer = new ResizeObserver(resize);
|
||||||
|
observer.observe(canvas);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return;
|
||||||
|
let frame = 0;
|
||||||
|
const draw = (time: number) => {
|
||||||
|
drawGraph({
|
||||||
|
context,
|
||||||
|
width: canvasSize.width,
|
||||||
|
height: canvasSize.height,
|
||||||
|
dpr: canvasSize.dpr,
|
||||||
|
time,
|
||||||
|
stars,
|
||||||
|
graph,
|
||||||
|
viewport,
|
||||||
|
selectedNodeId,
|
||||||
|
selectedEdgeId,
|
||||||
|
connectFrom,
|
||||||
|
query,
|
||||||
|
typeFilter,
|
||||||
|
});
|
||||||
|
frame = window.requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
frame = window.requestAnimationFrame(draw);
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [canvasSize, stars, graph, viewport, selectedNodeId, selectedEdgeId, connectFrom, query, typeFilter]);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNode = (id: string, patch: Partial<GraphNode>) => {
|
||||||
|
setGraph((current) => ({ ...current, nodes: current.nodes.map((node) => node.id === id ? { ...node, ...patch } : node) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEdge = (id: string, patch: Partial<GraphEdge>) => {
|
||||||
|
setGraph((current) => ({ ...current, edges: current.edges.map((edge) => edge.id === id ? { ...edge, ...patch } : edge) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointInCanvas = (event: { clientX: number; clientY: number }) => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
return { x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const screenPoint = (node: GraphNode) => ({
|
||||||
|
x: canvasSize.width / 2 + viewport.x + node.x * viewport.scale,
|
||||||
|
y: canvasSize.height / 2 + viewport.y + node.y * viewport.scale,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hitNode = (point: { x: number; y: number }) => {
|
||||||
|
for (let index = graph.nodes.length - 1; index >= 0; index -= 1) {
|
||||||
|
const node = graph.nodes[index];
|
||||||
|
const screen = screenPoint(node);
|
||||||
|
const card = nodeCardSize(node, viewport.scale);
|
||||||
|
if (Math.abs(point.x - screen.x) <= card.width / 2 + 5 && Math.abs(point.y - screen.y) <= card.height / 2 + 5) return node;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hitEdge = (point: { x: number; y: number }) => graph.edges.find((edge) => {
|
||||||
|
const source = graph.nodes.find((node) => node.id === edge.source);
|
||||||
|
const target = graph.nodes.find((node) => node.id === edge.target);
|
||||||
|
if (!source || !target) return false;
|
||||||
|
return distanceToSegment(point, screenPoint(source), screenPoint(target)) < 9;
|
||||||
|
}) ?? null;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const point = pointInCanvas(event);
|
||||||
|
const node = hitNode(point);
|
||||||
|
if (connectMode && node) return;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
interactionRef.current = node
|
||||||
|
? { mode: "node", nodeId: node.id, startX: point.x, startY: point.y, baseX: node.x, baseY: node.y, moved: false }
|
||||||
|
: { mode: "pan", startX: point.x, startY: point.y, baseX: viewport.x, baseY: viewport.y, moved: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const interaction = interactionRef.current;
|
||||||
|
const point = pointInCanvas(event);
|
||||||
|
if (!interaction) {
|
||||||
|
const node = hitNode(point);
|
||||||
|
setHoveredNodeId(node?.id ?? null);
|
||||||
|
setHoverPoint({
|
||||||
|
x: clamp(point.x + 15, 12, Math.max(12, canvasSize.width - 238)),
|
||||||
|
y: clamp(point.y + 15, 58, Math.max(58, canvasSize.height - 150)),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHoveredNodeId(null);
|
||||||
|
const dx = point.x - interaction.startX;
|
||||||
|
const dy = point.y - interaction.startY;
|
||||||
|
if (Math.hypot(dx, dy) > 3) interaction.moved = true;
|
||||||
|
if (interaction.mode === "node" && interaction.nodeId) {
|
||||||
|
updateNode(interaction.nodeId, { x: interaction.baseX + dx / viewport.scale, y: interaction.baseY + dy / viewport.scale });
|
||||||
|
} else {
|
||||||
|
setViewport((current) => ({ ...current, x: interaction.baseX + dx, y: interaction.baseY + dy }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (interactionRef.current?.moved) suppressClickRef.current = true;
|
||||||
|
interactionRef.current = null;
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasClick = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (suppressClickRef.current) {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const point = pointInCanvas(event);
|
||||||
|
const node = hitNode(point);
|
||||||
|
if (node) {
|
||||||
|
if (connectMode) {
|
||||||
|
if (!connectFrom) {
|
||||||
|
setConnectFrom(node.id);
|
||||||
|
setSelectedNodeId(node.id);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
notify(`已选择起点:${node.label}`);
|
||||||
|
} else if (connectFrom === node.id) {
|
||||||
|
notify("请选择另一个节点");
|
||||||
|
} else {
|
||||||
|
setPendingEdge({ source: connectFrom, target: node.id });
|
||||||
|
setNewEdge({ label: "关联", type: "自定义" });
|
||||||
|
setShowAddEdge(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedNodeId(node.id);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (connectMode) return;
|
||||||
|
const edge = hitEdge(point);
|
||||||
|
setSelectedEdgeId(edge?.id ?? null);
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoubleClick = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const point = pointInCanvas(event);
|
||||||
|
if (hitNode(point)) return;
|
||||||
|
setAddPoint({
|
||||||
|
x: (point.x - canvasSize.width / 2 - viewport.x) / viewport.scale,
|
||||||
|
y: (point.y - canvasSize.height / 2 - viewport.y) / viewport.scale,
|
||||||
|
});
|
||||||
|
setShowAddNode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (event: ReactWheelEvent<HTMLCanvasElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const point = pointInCanvas(event);
|
||||||
|
const nextScale = clamp(viewport.scale * (event.deltaY > 0 ? 0.9 : 1.1), 0.45, 2.2);
|
||||||
|
const worldX = (point.x - canvasSize.width / 2 - viewport.x) / viewport.scale;
|
||||||
|
const worldY = (point.y - canvasSize.height / 2 - viewport.y) / viewport.scale;
|
||||||
|
setViewport({
|
||||||
|
scale: nextScale,
|
||||||
|
x: point.x - canvasSize.width / 2 - worldX * nextScale,
|
||||||
|
y: point.y - canvasSize.height / 2 - worldY * nextScale,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddNode = () => {
|
||||||
|
setAddPoint({ x: -viewport.x / viewport.scale, y: -viewport.y / viewport.scale });
|
||||||
|
setNewNode({ label: "", type: "character", description: "", tags: "" });
|
||||||
|
setShowAddNode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNode = () => {
|
||||||
|
const label = newNode.label.trim();
|
||||||
|
if (!label) return notify("请输入节点名称");
|
||||||
|
const nodeType = graph.nodeTypes.find((type) => type.id === newNode.type) ?? graph.nodeTypes[0];
|
||||||
|
const node: GraphNode = {
|
||||||
|
id: `node-${Date.now()}`,
|
||||||
|
label,
|
||||||
|
type: nodeType.id,
|
||||||
|
x: addPoint.x,
|
||||||
|
y: addPoint.y,
|
||||||
|
color: nodeType.color,
|
||||||
|
size: 21,
|
||||||
|
description: newNode.description.trim() || "暂无说明",
|
||||||
|
tags: newNode.tags.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
setGraph((current) => ({ ...current, nodes: [...current.nodes, node] }));
|
||||||
|
setSelectedNodeId(node.id);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
setShowAddNode(false);
|
||||||
|
notify("节点已添加");
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEdge = () => {
|
||||||
|
if (!pendingEdge.source || !pendingEdge.target) return;
|
||||||
|
const duplicate = graph.edges.some((edge) =>
|
||||||
|
(edge.source === pendingEdge.source && edge.target === pendingEdge.target)
|
||||||
|
|| (edge.source === pendingEdge.target && edge.target === pendingEdge.source));
|
||||||
|
if (duplicate) {
|
||||||
|
setShowAddEdge(false);
|
||||||
|
setConnectFrom(null);
|
||||||
|
return notify("这两个节点已经存在关联");
|
||||||
|
}
|
||||||
|
const edge: GraphEdge = {
|
||||||
|
id: `edge-${Date.now()}`,
|
||||||
|
source: pendingEdge.source,
|
||||||
|
target: pendingEdge.target,
|
||||||
|
label: newEdge.label.trim() || "关联",
|
||||||
|
type: newEdge.type,
|
||||||
|
};
|
||||||
|
setGraph((current) => ({ ...current, edges: [...current.edges, edge] }));
|
||||||
|
setSelectedEdgeId(edge.id);
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setShowAddEdge(false);
|
||||||
|
setConnectFrom(null);
|
||||||
|
notify("直线关联已建立");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNode = (id: string) => {
|
||||||
|
setGraph((current) => ({
|
||||||
|
...current,
|
||||||
|
nodes: current.nodes.filter((node) => node.id !== id),
|
||||||
|
edges: current.edges.filter((edge) => edge.source !== id && edge.target !== id),
|
||||||
|
}));
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
if (connectFrom === id) setConnectFrom(null);
|
||||||
|
notify("节点及其关联已删除");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEdge = (id: string) => {
|
||||||
|
setGraph((current) => ({ ...current, edges: current.edges.filter((edge) => edge.id !== id) }));
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
notify("关联已删除");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetGraph = () => {
|
||||||
|
setGraph(cloneGraph(INITIAL_GRAPH));
|
||||||
|
setViewport({ x: 0, y: 0, scale: 1 });
|
||||||
|
setSelectedNodeId("project-starsea");
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
setConnectFrom(null);
|
||||||
|
notify("已恢复演示数据");
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportGraph = () => {
|
||||||
|
const blob = new Blob([JSON.stringify(graph, null, 2)], { type: "application/json;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = "star-graph.json";
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
notify("JSON 已导出");
|
||||||
|
};
|
||||||
|
|
||||||
|
const importGraph = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await file.text());
|
||||||
|
if (!isGraphData(parsed)) throw new Error("invalid");
|
||||||
|
setGraph(parsed);
|
||||||
|
setSelectedNodeId(parsed.nodes[0]?.id ?? null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
notify("JSON 已导入");
|
||||||
|
} catch {
|
||||||
|
notify("JSON 格式不正确");
|
||||||
|
}
|
||||||
|
event.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusNode = (node: GraphNode) => {
|
||||||
|
setViewport((current) => ({ ...current, x: -node.x * current.scale, y: -node.y * current.scale }));
|
||||||
|
setSelectedNodeId(node.id);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<Link className={styles.brand} href="/"><span>✦</span><div><strong>StarGraph</strong><small>星空关系网络</small></div></Link>
|
||||||
|
<nav><Link href="/worklist">工作列表</Link><Link href="/approval">审批流程</Link><Link className={styles.activeNav} href="/stargraph">星图网络</Link></nav>
|
||||||
|
<div className={styles.headerStats}><span><b>{graph.nodes.length}</b> 节点</span><span><b>{graph.edges.length}</b> 关联</span><em>{hydrated ? "● 已自动保存" : "正在读取…"}</em></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.workspace}>
|
||||||
|
<aside className={styles.sidebar}>
|
||||||
|
<div className={styles.sideHeading}><span>GRAPH EXPLORER</span><h1>{graph.title}</h1><p>{graph.subtitle}</p></div>
|
||||||
|
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点或标签" />{query && <button onClick={() => setQuery("")}>×</button>}</label>
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<button className={typeFilter === "all" ? styles.filterActive : ""} onClick={() => setTypeFilter("all")}><i style={{ background: "#a9b1d6" }} />全部<b>{graph.nodes.length}</b></button>
|
||||||
|
{graph.nodeTypes.map((type) => <button key={type.id} className={typeFilter === type.id ? styles.filterActive : ""} onClick={() => setTypeFilter(type.id)}><i style={{ background: type.color }} />{type.name}<b>{graph.nodes.filter((node) => node.type === type.id).length}</b></button>)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.nodeListHeader}><span>节点列表</span><b>{visibleNodes.length}</b></div>
|
||||||
|
<div className={styles.nodeList}>
|
||||||
|
{visibleNodes.map((node) => <button key={node.id} className={selectedNodeId === node.id ? styles.nodeActive : ""} onClick={() => focusNode(node)}><i style={{ background: node.color, boxShadow: `0 0 12px ${node.color}` }} /><span><strong>{node.label}</strong><small>{typeName(graph, node.type)} · {graph.edges.filter((edge) => edge.source === node.id || edge.target === node.id).length} 条关联</small></span><em>›</em></button>)}
|
||||||
|
{visibleNodes.length === 0 && <p className={styles.emptyList}>没有匹配的节点</p>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.dataActions}><button onClick={exportGraph}>⇩ 导出 JSON</button><button onClick={() => importRef.current?.click()}>⇧ 导入</button><button onClick={resetGraph}>↺ 重置</button><input ref={importRef} type="file" accept="application/json,.json" onChange={importGraph} /></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className={`${styles.stage} ${connectMode ? styles.connecting : ""}`}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={styles.canvas}
|
||||||
|
role="img"
|
||||||
|
aria-label={`星空关系图,共 ${graph.nodes.length} 个节点和 ${graph.edges.length} 条直线关联。可拖动节点或画布,滚轮缩放。`}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onPointerLeave={() => setHoveredNodeId(null)}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
/>
|
||||||
|
{hoveredNode && <aside className={hoverStyles.hoverCard} style={{ left: hoverPoint.x, top: hoverPoint.y }} aria-live="polite">
|
||||||
|
<div className={hoverStyles.hoverHeader}><i style={{ background: hoveredNode.color, boxShadow: `0 0 12px ${hoveredNode.color}` }} /><span><small>{typeName(graph, hoveredNode.type)}</small><strong>{hoveredNode.label}</strong></span><em>{graph.edges.filter((edge) => edge.source === hoveredNode.id || edge.target === hoveredNode.id).length} 关联</em></div>
|
||||||
|
<p>{hoveredNode.description}</p>
|
||||||
|
{hoveredNode.tags.length > 0 && <div className={hoverStyles.hoverTags}>{hoveredNode.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>}
|
||||||
|
</aside>}
|
||||||
|
<div className={styles.canvasToolbar}>
|
||||||
|
<button className={styles.primaryButton} onClick={openAddNode}><span>+</span>添加节点</button>
|
||||||
|
<button className={connectMode ? styles.connectButtonActive : ""} onClick={() => { setConnectMode((value) => !value); setConnectFrom(null); }}><span>⌁</span>{connectMode ? "退出关联" : "关联节点"}</button>
|
||||||
|
<i />
|
||||||
|
<button title="放大" onClick={() => setViewport((current) => ({ ...current, scale: clamp(current.scale + 0.15, 0.45, 2.2) }))}>+</button>
|
||||||
|
<button title="缩小" onClick={() => setViewport((current) => ({ ...current, scale: clamp(current.scale - 0.15, 0.45, 2.2) }))}>−</button>
|
||||||
|
<button title="居中" onClick={() => setViewport({ x: 0, y: 0, scale: 1 })}>⌾</button>
|
||||||
|
<span className={styles.zoomValue}>{Math.round(viewport.scale * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
{connectMode && <div className={styles.connectHint}><span>{connectFrom ? "2" : "1"}</span><p><strong>{fromNode ? `起点:${fromNode.label}` : "请选择第一个节点"}</strong><small>{fromNode ? "再点击另一个表单节点,即可建立直线关联" : "点击任意方形表单节点作为关联起点"}</small></p>{connectFrom && <button onClick={() => setConnectFrom(null)}>重选</button>}</div>}
|
||||||
|
<div className={styles.canvasHelp}><span>拖动画布移动</span><span>滚轮缩放</span><span>拖动节点调整位置</span><span>双击空白处添加</span></div>
|
||||||
|
<div className={styles.minimapLabel}><span>LIVE MAP</span><b>直线关系模式</b></div>
|
||||||
|
<p className={styles.srOnly}>节点包括:{graph.nodes.map((node) => node.label).join("、")}。关联包括:{graph.edges.map((edge) => `${nodeLabel(graph, edge.source)}与${nodeLabel(graph, edge.target)}:${edge.label}`).join(";")}。</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside className={styles.inspector}>
|
||||||
|
<div className={styles.inspectorHeading}><div><span>INSPECTOR</span><strong>{selectedNode ? "节点属性" : selectedEdge ? "关联属性" : "属性面板"}</strong></div><small>{selectedNode ? "NODE" : selectedEdge ? "EDGE" : "EMPTY"}</small></div>
|
||||||
|
{selectedNode ? <>
|
||||||
|
<div className={styles.nodePreview}><i style={{ background: selectedNode.color, boxShadow: `0 0 28px ${selectedNode.color}`, borderRadius: "7px" }} /><div><strong>{selectedNode.label}</strong><span>{typeName(graph, selectedNode.type)}</span></div><em>{graph.edges.filter((edge) => edge.source === selectedNode.id || edge.target === selectedNode.id).length}</em></div>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label><span>节点名称</span><input value={selectedNode.label} onChange={(event) => updateNode(selectedNode.id, { label: event.target.value })} /></label>
|
||||||
|
<label><span>节点类型</span><select value={selectedNode.type} onChange={(event) => { const type = graph.nodeTypes.find((item) => item.id === event.target.value); updateNode(selectedNode.id, { type: event.target.value, color: type?.color ?? selectedNode.color }); }}>{graph.nodeTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
|
||||||
|
<label><span>说明</span><textarea rows={4} value={selectedNode.description} onChange={(event) => updateNode(selectedNode.id, { description: event.target.value })} /></label>
|
||||||
|
<label><span>标签(逗号分隔)</span><input value={selectedNode.tags.join(",")} onChange={(event) => updateNode(selectedNode.id, { tags: event.target.value.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean) })} /></label>
|
||||||
|
<label><span>发光颜色</span><div className={styles.colorInput}><input type="color" value={selectedNode.color} onChange={(event) => updateNode(selectedNode.id, { color: event.target.value })} /><code>{selectedNode.color.toUpperCase()}</code></div></label>
|
||||||
|
<label><span>节点大小</span><div className={styles.rangeInput}><input type="range" min="14" max="36" value={selectedNode.size} onChange={(event) => updateNode(selectedNode.id, { size: Number(event.target.value) })} /><b>{selectedNode.size}</b></div></label>
|
||||||
|
</div>
|
||||||
|
<div className={styles.relationBlock}><div><strong>已连接节点</strong><span>{graph.edges.filter((edge) => edge.source === selectedNode.id || edge.target === selectedNode.id).length}</span></div>{graph.edges.filter((edge) => edge.source === selectedNode.id || edge.target === selectedNode.id).map((edge) => { const otherId = edge.source === selectedNode.id ? edge.target : edge.source; const other = graph.nodes.find((node) => node.id === otherId); return <button key={edge.id} onClick={() => { setSelectedEdgeId(edge.id); setSelectedNodeId(null); }}><i style={{ background: other?.color }} /><span><strong>{other?.label}</strong><small>{edge.label}</small></span><em>›</em></button>; })}</div>
|
||||||
|
<button className={styles.deleteButton} onClick={() => deleteNode(selectedNode.id)}>删除此节点</button>
|
||||||
|
</> : selectedEdge ? <>
|
||||||
|
<div className={styles.edgePreview}><span style={{ background: graph.nodes.find((node) => node.id === selectedEdge.source)?.color, borderRadius: "3px" }} /><i /><span style={{ background: graph.nodes.find((node) => node.id === selectedEdge.target)?.color, borderRadius: "3px" }} /></div>
|
||||||
|
<div className={styles.edgeEndpoints}><strong>{nodeLabel(graph, selectedEdge.source)}</strong><small>直线连接至</small><strong>{nodeLabel(graph, selectedEdge.target)}</strong></div>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label><span>关联名称</span><input value={selectedEdge.label} onChange={(event) => updateEdge(selectedEdge.id, { label: event.target.value })} /></label>
|
||||||
|
<label><span>关联类型</span><select value={selectedEdge.type} onChange={(event) => updateEdge(selectedEdge.id, { type: event.target.value })}>{RELATION_TYPES.map((type) => <option key={type}>{type}</option>)}</select></label>
|
||||||
|
</div>
|
||||||
|
<div className={styles.edgeNote}><span>╱</span><p><strong>直线关联</strong><small>节点移动后,连接线会自动跟随更新。</small></p></div>
|
||||||
|
<button className={styles.deleteButton} onClick={() => deleteEdge(selectedEdge.id)}>删除此关联</button>
|
||||||
|
</> : <div className={styles.emptyInspector}><span>✦</span><h2>选择一个节点</h2><p>点击画布中的节点或连接线,可在这里查看和修改属性。</p><button onClick={openAddNode}>+ 添加第一个节点</button></div>}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddNode && <div className={styles.modalBackdrop} onMouseDown={(event) => { if (event.target === event.currentTarget) setShowAddNode(false); }}><section className={styles.modal} role="dialog" aria-modal="true" aria-labelledby="add-node-title"><div className={styles.modalHeader}><div><span>NEW STAR</span><h2 id="add-node-title">添加新节点</h2></div><button onClick={() => setShowAddNode(false)}>×</button></div><div className={styles.modalBody}><label><span>节点名称 *</span><input autoFocus value={newNode.label} onChange={(event) => setNewNode({ ...newNode, label: event.target.value })} placeholder="例如:新角色、线索或地点" onKeyDown={(event) => { if (event.key === "Enter") createNode(); }} /></label><label><span>节点类型</span><div className={styles.typeCards}>{graph.nodeTypes.map((type) => <button key={type.id} className={newNode.type === type.id ? styles.typeCardActive : ""} onClick={() => setNewNode({ ...newNode, type: type.id })}><i style={{ background: type.color, boxShadow: `0 0 12px ${type.color}` }} />{type.name}</button>)}</div></label><label><span>说明</span><textarea rows={3} value={newNode.description} onChange={(event) => setNewNode({ ...newNode, description: event.target.value })} placeholder="简要描述这个节点" /></label><label><span>标签</span><input value={newNode.tags} onChange={(event) => setNewNode({ ...newNode, tags: event.target.value })} placeholder="使用逗号分隔多个标签" /></label></div><div className={styles.modalFooter}><button onClick={() => setShowAddNode(false)}>取消</button><button className={styles.modalPrimary} onClick={createNode}>添加到星图</button></div></section></div>}
|
||||||
|
|
||||||
|
{showAddEdge && <div className={styles.modalBackdrop} onMouseDown={(event) => { if (event.target === event.currentTarget) setShowAddEdge(false); }}><section className={`${styles.modal} ${styles.edgeModal}`} role="dialog" aria-modal="true" aria-labelledby="add-edge-title"><div className={styles.modalHeader}><div><span>NEW CONNECTION</span><h2 id="add-edge-title">建立直线关联</h2></div><button onClick={() => setShowAddEdge(false)}>×</button></div><div className={styles.connectionPreview}><div><i style={{ background: graph.nodes.find((node) => node.id === pendingEdge.source)?.color, borderRadius: "5px" }} /><strong>{nodeLabel(graph, pendingEdge.source)}</strong></div><span><b>────────</b><small>直线</small></span><div><i style={{ background: graph.nodes.find((node) => node.id === pendingEdge.target)?.color, borderRadius: "5px" }} /><strong>{nodeLabel(graph, pendingEdge.target)}</strong></div></div><div className={styles.modalBody}><label><span>关联名称</span><input autoFocus value={newEdge.label} onChange={(event) => setNewEdge({ ...newEdge, label: event.target.value })} placeholder="例如:好友、所属、依赖" onKeyDown={(event) => { if (event.key === "Enter") createEdge(); }} /></label><label><span>关联类型</span><select value={newEdge.type} onChange={(event) => setNewEdge({ ...newEdge, type: event.target.value })}>{RELATION_TYPES.map((type) => <option key={type}>{type}</option>)}</select></label></div><div className={styles.modalFooter}><button onClick={() => setShowAddEdge(false)}>取消</button><button className={styles.modalPrimary} onClick={createEdge}>确认关联</button></div></section></div>}
|
||||||
|
|
||||||
|
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGraph(options: {
|
||||||
|
context: CanvasRenderingContext2D;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
dpr: number;
|
||||||
|
time: number;
|
||||||
|
stars: { x: number; y: number; size: number; alpha: number; phase: number }[];
|
||||||
|
graph: GraphData;
|
||||||
|
viewport: { x: number; y: number; scale: number };
|
||||||
|
selectedNodeId: string | null;
|
||||||
|
selectedEdgeId: string | null;
|
||||||
|
connectFrom: string | null;
|
||||||
|
query: string;
|
||||||
|
typeFilter: string;
|
||||||
|
}) {
|
||||||
|
const { context: ctx, width, height, dpr, time, stars, graph, viewport, selectedNodeId, selectedEdgeId, connectFrom, query, typeFilter } = options;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
const background = ctx.createLinearGradient(0, 0, width, height);
|
||||||
|
background.addColorStop(0, "#070a19");
|
||||||
|
background.addColorStop(0.48, "#10112b");
|
||||||
|
background.addColorStop(1, "#070b1b");
|
||||||
|
ctx.fillStyle = background;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
drawNebula(ctx, width * 0.2, height * 0.28, Math.max(width, height) * 0.42, "111,89,255");
|
||||||
|
drawNebula(ctx, width * 0.82, height * 0.68, Math.max(width, height) * 0.38, "39,199,217");
|
||||||
|
stars.forEach((star) => {
|
||||||
|
const pulse = 0.68 + Math.sin(time * 0.0014 + star.phase) * 0.32;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = `rgba(218,231,255,${star.alpha * pulse})`;
|
||||||
|
ctx.arc(star.x * width, star.y * height, star.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
const toScreen = (node: GraphNode) => ({ x: width / 2 + viewport.x + node.x * viewport.scale, y: height / 2 + viewport.y + node.y * viewport.scale });
|
||||||
|
const matches = (node: GraphNode) => {
|
||||||
|
const search = query.trim().toLowerCase();
|
||||||
|
return (typeFilter === "all" || node.type === typeFilter) && (!search || `${node.label} ${node.tags.join(" ")}`.toLowerCase().includes(search));
|
||||||
|
};
|
||||||
|
|
||||||
|
graph.edges.forEach((edge) => {
|
||||||
|
const source = graph.nodes.find((node) => node.id === edge.source);
|
||||||
|
const target = graph.nodes.find((node) => node.id === edge.target);
|
||||||
|
if (!source || !target) return;
|
||||||
|
const sourceCenter = toScreen(source);
|
||||||
|
const targetCenter = toScreen(target);
|
||||||
|
const a = cardEdgePoint(sourceCenter, targetCenter, nodeCardSize(source, viewport.scale));
|
||||||
|
const b = cardEdgePoint(targetCenter, sourceCenter, nodeCardSize(target, viewport.scale));
|
||||||
|
const active = edge.id === selectedEdgeId;
|
||||||
|
const faded = !matches(source) || !matches(target);
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = faded ? 0.09 : active ? 1 : 0.48;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(a.x, a.y);
|
||||||
|
ctx.lineTo(b.x, b.y);
|
||||||
|
ctx.lineWidth = active ? 2.4 : 1.15;
|
||||||
|
ctx.strokeStyle = active ? "#e8e5ff" : "#7e86bc";
|
||||||
|
ctx.shadowBlur = active ? 12 : 4;
|
||||||
|
ctx.shadowColor = active ? "#9b8aff" : "#4d60a3";
|
||||||
|
ctx.stroke();
|
||||||
|
const mx = (a.x + b.x) / 2;
|
||||||
|
const my = (a.y + b.y) / 2;
|
||||||
|
ctx.font = '500 10px "Microsoft YaHei", "PingFang SC", sans-serif';
|
||||||
|
const labelWidth = ctx.measureText(edge.label).width + 14;
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.fillStyle = active ? "rgba(111,92,214,.92)" : "rgba(13,17,43,.76)";
|
||||||
|
ctx.fillRect(mx - labelWidth / 2, my - 9, labelWidth, 18);
|
||||||
|
ctx.fillStyle = active ? "#ffffff" : "#aeb6dd";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(edge.label, mx, my);
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
graph.nodes.forEach((node) => {
|
||||||
|
const point = toScreen(node);
|
||||||
|
const card = nodeCardSize(node, viewport.scale);
|
||||||
|
const left = point.x - card.width / 2;
|
||||||
|
const top = point.y - card.height / 2;
|
||||||
|
const active = node.id === selectedNodeId;
|
||||||
|
const linking = node.id === connectFrom;
|
||||||
|
const faded = !matches(node);
|
||||||
|
const relationCount = graph.edges.filter((edge) => edge.source === node.id || edge.target === node.id).length;
|
||||||
|
const pulse = 0.78 + Math.sin(time * 0.002 + node.x * 0.01) * 0.18;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = faded ? 0.16 : 1;
|
||||||
|
roundedRectPath(ctx, left, top, card.width, card.height, 5);
|
||||||
|
ctx.fillStyle = "rgba(11,15,34,.96)";
|
||||||
|
ctx.shadowBlur = active || linking ? 22 : 9 * pulse;
|
||||||
|
ctx.shadowColor = node.color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.lineWidth = active || linking ? 1.8 : 1;
|
||||||
|
ctx.setLineDash(linking ? [5, 3] : []);
|
||||||
|
ctx.strokeStyle = linking ? "#ffffff" : active ? node.color : "rgba(135,145,190,.35)";
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
ctx.fillStyle = node.color;
|
||||||
|
ctx.fillRect(left, top, 3, card.height);
|
||||||
|
ctx.fillStyle = hexToRgba(node.color, active ? 0.18 : 0.1);
|
||||||
|
ctx.fillRect(left + 3, top, card.width - 3, card.height);
|
||||||
|
|
||||||
|
const padding = 7;
|
||||||
|
const markerSize = clamp(card.height - 13, 9, 13);
|
||||||
|
ctx.fillStyle = node.color;
|
||||||
|
roundedRectPath(ctx, left + padding, point.y - markerSize / 2, markerSize, markerSize, 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.font = `700 ${clamp(markerSize - 5, 7, 9)}px "Microsoft YaHei", "PingFang SC", sans-serif`;
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(typeGlyph(node.type), left + padding + markerSize / 2, point.y + .5);
|
||||||
|
ctx.font = `${active ? 700 : 600} ${clamp(card.height * .34, 9, 11)}px "Microsoft YaHei", "PingFang SC", sans-serif`;
|
||||||
|
ctx.fillStyle = active ? "#ffffff" : "#e2e7fb";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText(fitCanvasText(ctx, node.label, card.width - markerSize - 34), left + padding + markerSize + 6, point.y + .5);
|
||||||
|
ctx.font = '600 8px "Microsoft YaHei", "PingFang SC", sans-serif';
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.fillStyle = "#7f89aa";
|
||||||
|
ctx.fillText(String(relationCount), left + card.width - 7, point.y + .5);
|
||||||
|
if (active || linking) {
|
||||||
|
const portSize = 4;
|
||||||
|
ctx.fillStyle = linking ? "#ffffff" : node.color;
|
||||||
|
[[point.x, top], [left + card.width, point.y], [point.x, top + card.height], [left, point.y]].forEach(([x, y]) => ctx.fillRect(x - portSize / 2, y - portSize / 2, portSize, portSize));
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeCardSize(node: GraphNode, scale: number) {
|
||||||
|
const emphasis = clamp((node.size - 14) / 22, 0, 1);
|
||||||
|
return {
|
||||||
|
width: clamp((92 + emphasis * 14) * scale, 70, 118),
|
||||||
|
height: clamp((25 + emphasis * 5) * scale, 21, 32),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardEdgePoint(center: { x: number; y: number }, toward: { x: number; y: number }, card: { width: number; height: number }) {
|
||||||
|
const dx = toward.x - center.x;
|
||||||
|
const dy = toward.y - center.y;
|
||||||
|
if (dx === 0 && dy === 0) return center;
|
||||||
|
const xScale = dx === 0 ? Number.POSITIVE_INFINITY : card.width / 2 / Math.abs(dx);
|
||||||
|
const yScale = dy === 0 ? Number.POSITIVE_INFINITY : card.height / 2 / Math.abs(dy);
|
||||||
|
const scale = Math.min(xScale, yScale);
|
||||||
|
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
|
||||||
|
const r = Math.min(radius, width / 2, height / 2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + width - r, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
|
||||||
|
ctx.lineTo(x + width, y + height - r);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
|
||||||
|
ctx.lineTo(x + r, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
|
||||||
|
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||||
|
let value = text;
|
||||||
|
while (value.length > 1 && ctx.measureText(`${value}…`).width > maxWidth) value = value.slice(0, -1);
|
||||||
|
return `${value}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number) {
|
||||||
|
const clean = hex.replace("#", "");
|
||||||
|
const value = Number.parseInt(clean.length === 3 ? clean.split("").map((char) => `${char}${char}`).join("") : clean, 16);
|
||||||
|
const red = (value >> 16) & 255;
|
||||||
|
const green = (value >> 8) & 255;
|
||||||
|
const blue = value & 255;
|
||||||
|
return `rgba(${red},${green},${blue},${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawNebula(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, rgb: string) {
|
||||||
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
||||||
|
gradient.addColorStop(0, `rgba(${rgb},.12)`);
|
||||||
|
gradient.addColorStop(0.35, `rgba(${rgb},.055)`);
|
||||||
|
gradient.addColorStop(1, `rgba(${rgb},0)`);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStars(count: number) {
|
||||||
|
let seed = 12873;
|
||||||
|
const random = () => {
|
||||||
|
seed = (seed * 16807) % 2147483647;
|
||||||
|
return (seed - 1) / 2147483646;
|
||||||
|
};
|
||||||
|
return Array.from({ length: count }, () => ({ x: random(), y: random(), size: 0.3 + random() * 1.25, alpha: 0.25 + random() * 0.65, phase: random() * Math.PI * 2 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToSegment(point: { x: number; y: number }, a: { x: number; y: number }, b: { x: number; y: number }) {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
if (dx === 0 && dy === 0) return Math.hypot(point.x - a.x, point.y - a.y);
|
||||||
|
const t = clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy), 0, 1);
|
||||||
|
return Math.hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneGraph(graph: GraphData): GraphData {
|
||||||
|
return JSON.parse(JSON.stringify(graph));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGraphData(value: unknown): value is GraphData {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const candidate = value as Partial<GraphData>;
|
||||||
|
return Array.isArray(candidate.nodes) && Array.isArray(candidate.edges) && Array.isArray(candidate.nodeTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeLabel(graph: GraphData, id: string) {
|
||||||
|
return graph.nodes.find((node) => node.id === id)?.label ?? "未知节点";
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeName(graph: GraphData, id: string) {
|
||||||
|
return graph.nodeTypes.find((type) => type.id === id)?.name ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeGlyph(type: string) {
|
||||||
|
return ({ project: "星", character: "人", world: "界", organization: "组", event: "事" } as Record<string, string>)[type] ?? "点";
|
||||||
|
}
|
||||||
49
package-lock.json
generated
49
package-lock.json
generated
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "orbitflow-admin-template",
|
"name": "wbbr",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "orbitflow-admin-template",
|
"name": "wbbr",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/d3-geo": "^3.1.1",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6"
|
"react-dom": "19.2.6"
|
||||||
@@ -2652,6 +2654,14 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-geo": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -2683,6 +2693,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -4296,6 +4311,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-geo": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.5.0 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -5768,6 +5805,14 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "orbitflow-admin-template",
|
"name": "wbbr",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -9,10 +9,13 @@
|
|||||||
"dev": "vinext dev",
|
"dev": "vinext dev",
|
||||||
"build": "vinext build",
|
"build": "vinext build",
|
||||||
"start": "vinext start",
|
"start": "vinext start",
|
||||||
|
"start:local": "node scripts/start-local.mjs",
|
||||||
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
||||||
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next"
|
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/d3-geo": "^3.1.1",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6"
|
"react-dom": "19.2.6"
|
||||||
|
|||||||
BIN
public/anime/star-echoes-hero.png
Normal file
BIN
public/anime/star-echoes-hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/anime/star-echoes-merch.png
Normal file
BIN
public/anime/star-echoes-merch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
11
public/maps/aus-adm1.geojson
Normal file
11
public/maps/aus-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
29
public/maps/bra-adm1.geojson
Normal file
29
public/maps/bra-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
15
public/maps/can-adm1.geojson
Normal file
15
public/maps/can-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
36
public/maps/chn-adm1.geojson
Normal file
36
public/maps/chn-adm1.geojson
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{"type":"FeatureCollection", "features": [
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[110.92511515700005,20.000419416000057],[109.65605222900007,20.019967807000057],[108.64704317900004,19.344157750000022],[108.66438010400009,18.504495973000076],[109.69766084900004,18.19513530000006],[110.46048556000005,18.721373680000056],[110.92511515700005,20.000419416000057]]]},"properties":{"shapeName":"Hainan Province","shapeISO":"CHN","shapeID":"43563684B67556328368055","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[121.73295436,25.16218442400003],[121.13656413300009,25.07741936000008],[120.23227011100005,23.852013140000054],[120.26555700600011,22.550750575000052],[120.97567746600009,22.546651610000026],[121.80923683200001,24.406670012000063],[121.73295436,25.16218442400003]]]},"properties":{"shapeName":"Taiwan Province","shapeISO":"CHN","shapeID":"43563684B32372453033755","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[112.00096129100007,24.85742471100008],[111.51130758700003,25.13277478400005],[111.12995080900004,26.469079374000046],[109.53818465300009,26.125609403000055],[107.84317247000007,25.285643135000043],[107.16047919800008,25.56549857400006],[106.03495960800007,24.780522356000063],[104.58104757100006,24.817365169000027],[105.24679533300008,24.22757213400007],[105.53706587800008,23.28252977300008],[106.64917925400005,22.569882809000035],[107.53354101200006,21.623385106000057],[108.52486917200008,21.946493519000057],[109.74212505200006,21.546600053000077],[111.41000969300006,22.964288484000065],[112.00096129100007,24.85742471100008]]]},"properties":{"shapeName":"Guangxi Zhuang Autonomous Region","shapeISO":"CHN","shapeID":"43563684B59914390554750","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[120.54140008200011,27.28741374300006],[119.04774973400004,27.55230679700003],[118.59094838800002,28.390088225000056],[117.39648296200005,27.938886529000058],[116.124420514,25.71597160500005],[115.96841599700008,24.97318308900003],[117.03290128800006,24.29101290400007],[117.22161590400003,23.642915019000043],[118.65044905000002,24.60228445000007],[119.5509982960001,25.66353303900007],[119.68192675500006,26.623705174000065],[120.54140008200011,27.28741374300006]]]},"properties":{"shapeName":"Fujian Province","shapeISO":"CHN","shapeID":"43563684B30737817496648","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.58104757100006,24.817365169000027],[103.85054070700005,26.669068563000053],[103.90194711500004,27.44274364200004],[105.36945876100003,27.865629813000055],[104.45871942800005,28.114820309000038],[103.02238244900008,27.489610241000037],[103.06025554000007,26.51779902900006],[101.68167502200004,26.32333983700005],[100.63195918000008,27.975545684000053],[99.39161544700005,28.246268822000047],[99.12145406400003,29.340099191000036],[98.15436570600008,28.213301262000073],[98.76087826500009,27.56826390200007],[98.75613770000007,25.91972372500004],[97.89121475200005,25.30019388100004],[97.77332366100006,24.239210525000033],[98.74765886000006,23.935326662000023],[99.50354880000003,23.121482770000057],[99.21936192100009,22.152586602000042],[100.62850720700004,21.435534302000065],[101.78156530100006,21.171143518000065],[101.71227307800007,22.45657176800006],[103.40463437300008,22.79237344300003],[103.99492200500003,22.50975544100004],[105.53706587800008,23.28252977300008],[105.24679533300008,24.22757213400007],[104.58104757100006,24.817365169000027]]]},"properties":{"shapeName":"Yunnan Province","shapeISO":"CHN","shapeID":"43563684B84540832148656","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.53818465300009,26.125609403000055],[109.33744975400003,28.612592041000028],[108.36349824400008,29.18813428900006],[106.37215745100008,28.674813466000046],[106.32996573900004,27.903191141000036],[105.36945876100003,27.865629813000055],[103.90194711500004,27.44274364200004],[103.85054070700005,26.669068563000053],[104.58104757100006,24.817365169000027],[106.03495960800007,24.780522356000063],[107.16047919800008,25.56549857400006],[107.84317247000007,25.285643135000043],[109.53818465300009,26.125609403000055]]]},"properties":{"shapeName":"Guizhou Province","shapeISO":"CHN","shapeID":"43563684B78583622565599","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[118.59094838800002,28.390088225000056],[118.29055000100004,29.512701785000047],[117.3552959760001,29.973697161000075],[116.2457002110001,29.88968471800007],[114.0489992570001,29.19659834600003],[114.3175584600001,28.458746934000033],[113.67845004600008,27.616086197000072],[114.16212098100004,26.67583714500006],[114.04385854500003,25.54367680300004],[114.48325323300003,24.58754335900005],[115.96841599700008,24.97318308900003],[116.124420514,25.71597160500005],[117.39648296200005,27.938886529000058],[118.59094838800002,28.390088225000056]]]},"properties":{"shapeName":"Jiangxi Province","shapeISO":"CHN","shapeID":"43563684B87813050901813","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[112.00096129100007,24.85742471100008],[112.28866540600006,25.26399870800003],[114.04385854500003,25.54367680300004],[114.16212098100004,26.67583714500006],[113.67845004600008,27.616086197000072],[114.3175584600001,28.458746934000033],[114.0489992570001,29.19659834600003],[112.90422938100005,29.86584339500007],[110.90533813000008,30.23649897300004],[109.53402924700003,29.67332785600007],[109.32884620600004,29.24048923600003],[109.33744975400003,28.612592041000028],[109.53818465300009,26.125609403000055],[111.12995080900004,26.469079374000046],[111.51130758700003,25.13277478400005],[112.00096129100007,24.85742471100008]]]},"properties":{"shapeName":"Hunan Province","shapeISO":"CHN","shapeID":"43563684B64987462919315","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[120.54140008200011,27.28741374300006],[121.61506326900007,28.30784592200007],[121.98316086600005,29.76415668100003],[121.41811606100009,30.710089537000044],[121.0018690170001,31.09295347400007],[119.73999731800006,31.22506099000003],[118.29055000100004,29.512701785000047],[118.59094838800002,28.390088225000056],[119.04774973400004,27.55230679700003],[120.54140008200011,27.28741374300006]]]},"properties":{"shapeName":"Zhejiang Province","shapeISO":"CHN","shapeID":"43563684B73528730180553","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[121.41811606100009,30.710089537000044],[121.37472026300009,31.550450451000074],[121.0018690170001,31.09295347400007],[121.41811606100009,30.710089537000044]]]},"properties":{"shapeName":"Shanghai Municipality","shapeISO":"CHN","shapeID":"43563684B32591653033375","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.33744975400003,28.612592041000028],[109.32884620600004,29.24048923600003],[108.53165476900006,30.540459231000057],[110.20564539700007,31.030671031000054],[109.73082396200004,31.972288990000038],[108.69871478700009,32.29969266300003],[108.73522263400008,31.70450243500005],[107.15559912500004,30.17007551900008],[105.81994144600009,30.46431847100007],[105.37303897000004,29.72815704900006],[106.37215745100008,28.674813466000046],[108.36349824400008,29.18813428900006],[109.33744975400003,28.612592041000028]]]},"properties":{"shapeName":"Chongqing Municipality","shapeISO":"CHN","shapeID":"43563684B97743196909507","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.2457002110001,29.88968471800007],[115.48626782300005,31.518355946000042],[114.83626200200001,31.615179685000044],[113.33917027300004,32.54311039600003],[111.61515560400005,32.74242918600004],[111.08785002600007,33.38212823400005],[109.72544616100004,33.39372350700006],[109.73082396200004,31.972288990000038],[110.20564539700007,31.030671031000054],[108.53165476900006,30.540459231000057],[109.32884620600004,29.24048923600003],[109.53402924700003,29.67332785600007],[110.90533813000008,30.23649897300004],[112.90422938100005,29.86584339500007],[114.0489992570001,29.19659834600003],[116.2457002110001,29.88968471800007]]]},"properties":{"shapeName":"Hubei Province","shapeISO":"CHN","shapeID":"43563684B41908353419915","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[105.36945876100003,27.865629813000055],[106.32996573900004,27.903191141000036],[106.37215745100008,28.674813466000046],[105.37303897000004,29.72815704900006],[105.81994144600009,30.46431847100007],[107.15559912500004,30.17007551900008],[108.73522263400008,31.70450243500005],[108.69871478700009,32.29969266300003],[108.07638865400008,32.27332686900007],[105.82575884100004,32.994325169000035],[105.19882275100008,32.65425219600007],[103.90356303500005,33.340347448000045],[103.04258143100009,34.323977564000074],[102.32147197400008,33.78566620200007],[100.99096452400005,32.745747401000074],[99.91789360900003,33.33441475400008],[97.66128859600008,33.27506559400007],[97.43043593900006,32.832427395000025],[98.29250678100004,32.38666753200005],[98.98763580900004,30.76418420600004],[99.12145406400003,29.340099191000036],[99.39161544700005,28.246268822000047],[100.63195918000008,27.975545684000053],[101.68167502200004,26.32333983700005],[103.06025554000007,26.51779902900006],[103.02238244900008,27.489610241000037],[104.45871942800005,28.114820309000038],[105.36945876100003,27.865629813000055]]]},"properties":{"shapeName":"Sichuan Province","shapeISO":"CHN","shapeID":"43563684B14397599951889","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[118.29055000100004,29.512701785000047],[119.73999731800006,31.22506099000003],[118.94123166500003,31.341306191000058],[118.83076848300004,32.423306172000025],[118.09697734300005,33.37725290800006],[116.45645125200008,34.73220183800004],[116.2696651010001,34.706579856000076],[115.48767917000009,33.630947898000045],[115.34123655100007,32.70100747400005],[115.99012884500007,32.486156720000054],[115.48626782300005,31.518355946000042],[116.2457002110001,29.88968471800007],[117.3552959760001,29.973697161000075],[118.29055000100004,29.512701785000047]]]},"properties":{"shapeName":"Anhui Province","shapeISO":"CHN","shapeID":"43563684B75549295130005","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[121.0018690170001,31.09295347400007],[121.37472026300009,31.550450451000074],[121.97567131400001,31.770872127000075],[120.96666226500008,32.87840046500003],[120.33213080000007,34.26505923600007],[119.44213042800004,35.186350823000055],[118.4978841090001,34.545417789000055],[116.45645125200008,34.73220183800004],[118.09697734300005,33.37725290800006],[118.83076848300004,32.423306172000025],[118.94123166500003,31.341306191000058],[119.73999731800006,31.22506099000003],[121.0018690170001,31.09295347400007]]]},"properties":{"shapeName":"Jiangsu Province","shapeISO":"CHN","shapeID":"43563684B64367354813847","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.48626782300005,31.518355946000042],[115.99012884500007,32.486156720000054],[115.34123655100007,32.70100747400005],[115.48767917000009,33.630947898000045],[116.2696651010001,34.706579856000076],[114.94853007400002,35.23663787800007],[115.54944646400008,36.22023203300006],[113.813192447,36.46220698500008],[113.6652161930001,35.69789371400003],[111.54924446600006,34.930083858000046],[110.43970339000003,34.71986913400008],[111.08785002600007,33.38212823400005],[111.61515560400005,32.74242918600004],[113.33917027300004,32.54311039600003],[114.83626200200001,31.615179685000044],[115.48626782300005,31.518355946000042]]]},"properties":{"shapeName":"Henan Province","shapeISO":"CHN","shapeID":"43563684B96917371447908","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.12145406400003,29.340099191000036],[98.98763580900004,30.76418420600004],[98.29250678100004,32.38666753200005],[97.43043593900006,32.832427395000025],[96.77363802200006,31.768251793000047],[95.60588438100007,31.85541661800005],[94.72612403400007,32.59298172300004],[93.52886094200005,32.55002754100008],[90.45104107000003,33.137616544000025],[89.56228586600008,34.26515440000003],[89.82024837300008,36.29902461800003],[87.45189623400006,36.504495605000045],[85.57904663100004,35.748794418000045],[84.20374222600003,35.43479658900003],[82.29550012900006,35.72172133600003],[81.89630360800004,35.252684018000025],[80.20540643400005,35.34046783300005],[79.77305126600004,34.40659406800006],[78.98356692300007,34.35575348300006],[78.73672959700008,34.048967719000075],[79.30603088100008,32.510731150000026],[78.75818404100005,31.888710282000034],[79.08065085200008,31.001274864000038],[81.08826679600008,30.16247949500007],[82.61586662300004,29.924045455000055],[83.69942445000004,28.99001659000004],[86.35955892000004,27.860833824000053],[87.75971880100008,27.755107663000047],[89.97762623000006,28.270777430000066],[92.06008893200004,27.702655726000046],[92.13188818200007,26.888238949000026],[93.73382007500004,27.011872261000065],[94.25739521900005,27.557313303000058],[95.86777886300007,28.286283840000067],[97.28723961800006,27.745818017000033],[98.15436570600008,28.213301262000073],[99.12145406400003,29.340099191000036]]]},"properties":{"shapeName":"Tibet Autonomous Region","shapeISO":"CHN","shapeID":"43563684B19802372559419","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.45645125200008,34.73220183800004],[118.4978841090001,34.545417789000055],[119.44213042800004,35.186350823000055],[121.58038941900008,37.45650827400004],[120.87997763700002,37.88465822000006],[119.65945810000005,37.13654169800003],[117.88327616900006,38.30339074800003],[116.1357280420001,37.45117518600006],[115.54944646400008,36.22023203300006],[114.94853007400002,35.23663787800007],[116.2696651010001,34.706579856000076],[116.45645125200008,34.73220183800004]]]},"properties":{"shapeName":"Shandong Province","shapeISO":"CHN","shapeID":"43563684B58995436924100","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.32147197400008,33.78566620200007],[101.69621828800007,34.25447003200003],[101.94830158300005,34.96201556700004],[102.99460884900009,35.940690853000035],[102.36591553500006,37.494954574000076],[101.49735931300006,37.760911391000036],[99.13660330000005,39.005604867000045],[97.12165886700006,39.208353818000035],[96.45013870400004,38.125790610000024],[93.30588311800005,38.66379111400005],[92.64618733400005,39.03463173800003],[90.69421787700009,38.688430433000065],[90.33915764800008,37.833483744000034],[91.17946685700008,37.45550514700005],[90.79038396600004,36.526573616000064],[89.82024837300008,36.29902461800003],[89.56228586600008,34.26515440000003],[90.45104107000003,33.137616544000025],[93.52886094200005,32.55002754100008],[94.72612403400007,32.59298172300004],[95.60588438100007,31.85541661800005],[96.77363802200006,31.768251793000047],[97.43043593900006,32.832427395000025],[97.66128859600008,33.27506559400007],[99.91789360900003,33.33441475400008],[100.99096452400005,32.745747401000074],[102.32147197400008,33.78566620200007]]]},"properties":{"shapeName":"Qinghai Province","shapeISO":"CHN","shapeID":"43563684B46172434476792","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.58140655100004,37.825796156000024],[106.63595984900007,38.386473815000045],[104.54273383900005,37.57352119500007],[105.29508352400006,36.97481740500007],[105.53564077400006,35.83661354000003],[106.36460699300005,35.42025567400003],[106.74919560800004,37.28246894700004],[107.34676546600008,37.21850670500004],[107.58140655100004,37.825796156000024]]]},"properties":{"shapeName":"Ningxia Ningxia Hui Autonomous Region","shapeISO":"CHN","shapeID":"43563684B64666899633249","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.73082396200004,31.972288990000038],[109.72544616100004,33.39372350700006],[111.08785002600007,33.38212823400005],[110.43970339000003,34.71986913400008],[110.68933297400008,35.63768155500003],[110.49515941000004,37.09783305700006],[111.27180531600004,39.502719666000075],[110.20736645200009,39.41455119900007],[108.76146688600005,37.79977388000003],[107.58140655100004,37.825796156000024],[107.34676546600008,37.21850670500004],[108.78256399700007,36.42761483900006],[108.53796695100004,35.379500208000024],[107.82390137900006,35.389149399000075],[106.76661091800008,34.490107167000076],[105.82575884100004,32.994325169000035],[108.07638865400008,32.27332686900007],[108.69871478700009,32.29969266300003],[109.73082396200004,31.972288990000038]]]},"properties":{"shapeName":"Shaanxi Province","shapeISO":"CHN","shapeID":"43563684B62959128536432","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[118.1192513850001,39.20940789700006],[117.49548738900012,40.30002925500003],[117.29679354500001,40.153405484000075],[117.03319528000009,39.77543450500008],[116.90421809400004,39.70628278500004],[117.64775917000009,38.68902605900007],[118.1192513850001,39.20940789700006]]]},"properties":{"shapeName":"Tianjin Municipality","shapeISO":"CHN","shapeID":"43563684B84701301190484","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[113.813192447,36.46220698500008],[114.21178010000006,37.70952527000003],[113.66809412400005,38.675726843000064],[114.622318489,39.59144874900005],[114.2123338670001,40.79157821800004],[112.38120016700009,40.36777938200004],[111.27180531600004,39.502719666000075],[110.49515941000004,37.09783305700006],[110.68933297400008,35.63768155500003],[110.43970339000003,34.71986913400008],[111.54924446600006,34.930083858000046],[113.6652161930001,35.69789371400003],[113.813192447,36.46220698500008]]]},"properties":{"shapeName":"Shanxi Province","shapeISO":"CHN","shapeID":"43563684B34303967365755","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[117.03319528000009,39.77543450500008],[117.29679354500001,40.153405484000075],[117.49548738900012,40.30002925500003],[116.75232480400007,41.073364550000065],[115.93426603700004,40.62753376400008],[115.84186715900012,39.628526125000064],[116.90421809400004,39.70628278500004],[117.03319528000009,39.77543450500008]]]},"properties":{"shapeName":"Beijing Municipality","shapeISO":"CHN","shapeID":"43563684B17418114845297","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[105.82575884100004,32.994325169000035],[106.76661091800008,34.490107167000076],[107.82390137900006,35.389149399000075],[108.53796695100004,35.379500208000024],[108.78256399700007,36.42761483900006],[107.34676546600008,37.21850670500004],[106.74919560800004,37.28246894700004],[106.36460699300005,35.42025567400003],[105.53564077400006,35.83661354000003],[105.29508352400006,36.97481740500007],[104.54273383900005,37.57352119500007],[103.63542352300004,37.79900673900005],[104.21919246900006,39.21603787500004],[101.60825152600006,38.92260820300004],[99.81734714700008,39.97864749300004],[100.13699670600005,40.91454229100003],[98.23535271800006,40.61499379500003],[97.42810213600006,41.46088059400006],[97.36665056300006,42.54598349100007],[96.40396180600004,42.65992465800008],[96.36729131700008,42.223246817000074],[94.63538642000003,41.541827700000056],[92.94814021100007,40.41965496800003],[92.64618733400005,39.03463173800003],[93.30588311800005,38.66379111400005],[96.45013870400004,38.125790610000024],[97.12165886700006,39.208353818000035],[99.13660330000005,39.005604867000045],[101.49735931300006,37.760911391000036],[102.36591553500006,37.494954574000076],[102.99460884900009,35.940690853000035],[101.94830158300005,34.96201556700004],[101.69621828800007,34.25447003200003],[102.32147197400008,33.78566620200007],[103.04258143100009,34.323977564000074],[103.90356303500005,33.340347448000045],[105.19882275100008,32.65425219600007],[105.82575884100004,32.994325169000035]]]},"properties":{"shapeName":"Gansu Province","shapeISO":"CHN","shapeID":"43563684B11053367304830","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.54944646400008,36.22023203300006],[116.1357280420001,37.45117518600006],[117.88327616900006,38.30339074800003],[117.64775917000009,38.68902605900007],[116.90421809400004,39.70628278500004],[115.84186715900012,39.628526125000064],[115.93426603700004,40.62753376400008],[116.75232480400007,41.073364550000065],[117.49548738900012,40.30002925500003],[118.1192513850001,39.20940789700006],[119.01257407700007,39.22308936600007],[119.97126983200008,40.07433721700005],[118.9562724110001,40.96104861500004],[119.28470498700005,41.38653246000007],[118.47014502800005,41.430991121000034],[117.82007967900006,42.66284883800006],[116.89914220900005,42.051193356000056],[114.03966465400003,41.550213126000074],[114.2123338670001,40.79157821800004],[114.622318489,39.59144874900005],[113.66809412400005,38.675726843000064],[114.21178010000006,37.70952527000003],[113.813192447,36.46220698500008],[115.54944646400008,36.22023203300006]]]},"properties":{"shapeName":"Hebei Province","shapeISO":"CHN","shapeID":"43563684B58229551045164","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[119.97126983200008,40.07433721700005],[121.21978137300005,40.95612581100005],[122.32934458900002,40.53581957800003],[121.53531341300004,39.887085755000044],[121.63475801600009,39.143682280000064],[124.11158050600011,39.847165767000035],[125.78043022800011,40.86282897400008],[125.40159198700007,42.25946281100005],[123.78385887000002,43.43983521000007],[122.86462215600011,42.79083568000004],[120.60952160400007,42.19336302100004],[119.62131686400005,42.46508224100006],[119.28470498700005,41.38653246000007],[118.9562724110001,40.96104861500004],[119.97126983200008,40.07433721700005]]]},"properties":{"shapeName":"Liaoning Province","shapeISO":"CHN","shapeID":"43563684B26929678147954","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[125.78043022800011,40.86282897400008],[126.62998561000006,41.63523130600004],[128.19572667300008,41.37962707500003],[128.683327694,41.97446621600005],[130.36284232800006,42.58773870700003],[131.32245463700008,43.451513834000025],[130.1693407460001,43.87889618600008],[128.9564550020001,43.58745081200004],[127.87494154400008,44.10936127400004],[127.02945437300002,45.17039842300005],[124.51379717000009,45.52715023700006],[123.16723220800009,46.29186184200006],[122.14210579700011,44.756763024000065],[123.1615170010001,44.55698082200007],[123.78385887000002,43.43983521000007],[125.40159198700007,42.25946281100005],[125.78043022800011,40.86282897400008]]]},"properties":{"shapeName":"Jilin Province","shapeISO":"CHN","shapeID":"43563684B3982601277390","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[89.82024837300008,36.29902461800003],[90.79038396600004,36.526573616000064],[91.17946685700008,37.45550514700005],[90.33915764800008,37.833483744000034],[90.69421787700009,38.688430433000065],[92.64618733400005,39.03463173800003],[92.94814021100007,40.41965496800003],[94.63538642000003,41.541827700000056],[96.36729131700008,42.223246817000074],[96.40396180600004,42.65992465800008],[95.37476005200006,44.17447266000005],[93.51645837500007,44.87743834100007],[90.84548833000008,45.130263789000026],[91.05136431600005,46.67585845200006],[90.10325121700004,47.79774587800006],[88.92759097200008,47.90318124800007],[87.53760299000004,49.05310566300005],[85.77443769200005,48.434334714000045],[85.57939728300005,47.09059050700006],[84.92926258600005,46.857698242000026],[83.25618157700006,47.25586166100004],[82.33999279900007,45.56843063000008],[80.07925774700004,45.08575561300006],[80.78507864700003,43.18122672000004],[80.25371055700003,42.15144354600005],[78.06470703200006,41.10521778900005],[76.79911148900004,41.07647194500004],[73.89322610500005,40.053334792000044],[73.89322610500005,38.59070460100003],[74.78716131200008,38.56105585200004],[74.95511277500003,37.60585193700007],[76.21154783500003,35.82997110800005],[77.90867470500007,35.451476945000024],[78.98356692300007,34.35575348300006],[79.77305126600004,34.40659406800006],[80.20540643400005,35.34046783300005],[81.89630360800004,35.252684018000025],[82.29550012900006,35.72172133600003],[84.20374222600003,35.43479658900003],[85.57904663100004,35.748794418000045],[87.45189623400006,36.504495605000045],[89.82024837300008,36.29902461800003]]]},"properties":{"shapeName":"Xinjiang Uyghur Autonomous Region","shapeISO":"CHN","shapeID":"43563684B50492231896073","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.58140655100004,37.825796156000024],[108.76146688600005,37.79977388000003],[110.20736645200009,39.41455119900007],[111.27180531600004,39.502719666000075],[112.38120016700009,40.36777938200004],[114.2123338670001,40.79157821800004],[114.03966465400003,41.550213126000074],[116.89914220900005,42.051193356000056],[117.82007967900006,42.66284883800006],[118.47014502800005,41.430991121000034],[119.28470498700005,41.38653246000007],[119.62131686400005,42.46508224100006],[120.60952160400007,42.19336302100004],[122.86462215600011,42.79083568000004],[123.78385887000002,43.43983521000007],[123.1615170010001,44.55698082200007],[122.14210579700011,44.756763024000065],[123.16723220800009,46.29186184200006],[122.64834401400003,47.58213902800003],[125.2779086060001,49.22872658400007],[125.94666695500007,51.18955509300008],[123.39861299800009,51.33258343300008],[122.83990349300007,52.254039843000044],[121.33680561800008,52.661541113000055],[121.59307227900001,53.40578626000007],[120.1479395130001,52.830647749000036],[120.75376482200011,52.24075800900005],[119.19149981300006,50.42333989200006],[117.71799986200006,49.57606669000006],[116.72826947300007,49.848704773000065],[115.5991502060001,47.90390764400007],[118.4597428730001,47.98984092000006],[119.72852841100007,47.199609750000036],[116.84748094500003,46.456630293000046],[115.64169640700004,45.44651470000008],[114.65255408700011,45.444498499000076],[113.64701242300009,44.81090599000004],[111.92025466600006,45.07106489000006],[111.44175553000008,44.37138360300003],[112.02774360400008,43.83606856500006],[110.41929302700004,42.78511665000008],[109.42308729700005,42.46795975800006],[107.39531717500006,42.45996633000004],[105.00282149200007,41.56862808900007],[102.22891345100004,42.093095564000066],[101.84750109500004,42.411344316000054],[97.36665056300006,42.54598349100007],[97.42810213600006,41.46088059400006],[98.23535271800006,40.61499379500003],[100.13699670600005,40.91454229100003],[99.81734714700008,39.97864749300004],[101.60825152600006,38.92260820300004],[104.21919246900006,39.21603787500004],[103.63542352300004,37.79900673900005],[104.54273383900005,37.57352119500007],[106.63595984900007,38.386473815000045],[107.58140655100004,37.825796156000024]]]},"properties":{"shapeName":"Inner Mongolia Autonomous Region","shapeISO":"CHN","shapeID":"43563684B95381459098578","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[131.32245463700008,43.451513834000025],[131.0563193370001,44.77753408500007],[132.94170996000003,44.965670844000044],[133.8627341130001,46.209200096000075],[134.3069928230001,48.36239217200006],[132.49203346100012,47.71761741500006],[130.95338134400004,47.72490692000008],[130.26532212400002,48.90304456500007],[128.65385492200005,49.64490856200007],[127.74540003900006,49.65388857700003],[127.41556503600009,50.648638608000056],[125.66731220200006,53.08562083400005],[123.64410657000008,53.57096340100003],[121.59307227900001,53.40578626000007],[121.33680561800008,52.661541113000055],[122.83990349300007,52.254039843000044],[123.39861299800009,51.33258343300008],[125.94666695500007,51.18955509300008],[125.2779086060001,49.22872658400007],[122.64834401400003,47.58213902800003],[123.16723220800009,46.29186184200006],[124.51379717000009,45.52715023700006],[127.02945437300002,45.17039842300005],[127.87494154400008,44.10936127400004],[128.9564550020001,43.58745081200004],[130.1693407460001,43.87889618600008],[131.32245463700008,43.451513834000025]]]},"properties":{"shapeName":"Heilongjiang Province","shapeISO":"CHN","shapeID":"43563684B55920014778896","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[113.62211382400005,22.204453236000063],[113.49713262300008,22.189988398000025],[113.56246370500003,22.091324835000023],[113.62211382400005,22.204453236000063]]]},"properties":{"shapeName":"Macau Special Administrative Region","shapeISO":"CHN","shapeID":"43563684B97104103456250","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[114.03961332353636,22.534065890046644],[114.10485686700008,22.326278496000043],[114.41427295200003,22.39107718500003],[114.2773802260001,22.60404713300005],[114.06234179677834,22.54168645556917],[114.03961332353636,22.534065890046644]]]},"properties":{"shapeName":"Hong Kong Special Administrative Region","shapeISO":"CHN","shapeID":"43563684B69230098435171","shapeGroup":"CHN","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.74212505200006,21.546600053000077],[110.21291426700009,21.048753142000066],[111.76630277100008,21.742987071000073],[113.01123269800007,21.92220334500007],[114.03961332353636,22.534065890046644],[114.06234179677834,22.54168645556917],[114.78778210400003,22.802218718000063],[115.84533454500001,22.78943250800006],[117.22161590400003,23.642915019000043],[117.03290128800006,24.29101290400007],[115.96841599700008,24.97318308900003],[114.48325323300003,24.58754335900005],[114.04385854500003,25.54367680300004],[112.28866540600006,25.26399870800003],[112.00096129100007,24.85742471100008],[111.41000969300006,22.964288484000065],[109.74212505200006,21.546600053000077]]]},"properties":{"shapeName":"Guangzhou Province","shapeISO":"CHN","shapeID":"43563684B38891657012300","shapeGroup":"CHN","shapeType":"ADM1"}}
|
||||||
|
]}
|
||||||
18
public/maps/deu-adm1.geojson
Normal file
18
public/maps/deu-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
1
public/maps/hebei-adm2.geojson
Normal file
1
public/maps/hebei-adm2.geojson
Normal file
File diff suppressed because one or more lines are too long
38
public/maps/ind-adm1.geojson
Normal file
38
public/maps/ind-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
49
public/maps/jpn-adm1.geojson
Normal file
49
public/maps/jpn-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
19
public/maps/kor-adm1.geojson
Normal file
19
public/maps/kor-adm1.geojson
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{"type":"FeatureCollection", "features": [
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[129.42261803539407,36.61664459826966],[129.47120201855842,36.7718773455631],[129.43799889393392,36.85614655162209],[129.41309654956615,37.02773672137266],[129.33106530012356,37.28217194158191],[129.14820397213566,37.505113023347405],[129.06421959665715,37.67885976826375],[128.92383873836718,37.802313543835055],[128.6316024099233,38.144964911101795],[128.5627547540288,38.28896718998391],[128.36491946666013,38.624335028039184],[128.27478478958642,38.571496887371495],[128.26806685390227,38.45351959275507],[128.1723621016298,38.35595448940819],[128.0398637291279,38.30427805608747],[127.80075687672854,38.29391693172107],[127.75373132334198,38.325362040925484],[127.47193973822834,38.295906474004596],[127.37711348509936,38.31851491313279],[127.15748864798275,38.30722361277958],[126.96606463557453,38.213038015991685],[127.08230024555996,38.11994741484136],[127.17676476528175,38.07380036149755],[127.29675743970415,38.0836188835047],[127.45168338418091,38.06095876753312],[127.50449669816419,37.948846746979825],[127.62583296062269,37.88109894486047],[127.53787967280869,37.79164704027875],[127.55648318894805,37.6777521839162],[127.53974002478242,37.60457835550676],[127.61632449787737,37.5930286727351],[127.83109175038408,37.508486030505935],[127.60702273980769,37.131351427426466],[127.91770145106113,37.1860250924841],[127.9875679866733,37.218271186466154],[128.03800418474543,37.150807603388216],[128.19106977814783,37.202794094172134],[128.32191450425103,37.17543142322165],[128.42071984284655,37.063732814717696],[128.50836307229804,37.07414561592748],[128.65026655475776,37.022262477931065],[128.75599653461245,36.99603668944172],[128.8038489120977,37.09287832327607],[129.06099083895617,37.02451040353236],[129.09809451664864,36.918728745934914],[129.16837446520947,36.84162750890249],[129.28609337830687,36.81956167303281],[129.28898725815554,36.656083279319944],[129.34717492068614,36.61174490020704],[129.42261803539407,36.61664459826966]]]},"properties":{"shapeName":"Gangwon","shapeISO":"KR-42","shapeID":"68945753B85747362819570","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[126.96606463557453,38.213038015991685],[126.91982873640711,38.134598490627866],[126.76190555839491,37.97900075258269],[126.66578739497231,37.93763376858362],[126.68685957081266,37.773627019571336],[126.53101647163294,37.75470612189008],[126.56051462505002,37.60735331530333],[126.60379237162067,37.549296673202946],[126.70061811713242,37.55202342394193],[126.79580610636674,37.4430378281333],[126.72004509120802,37.377053381608334],[126.73331008868968,37.3061704349177],[126.82254447424816,37.295606773333986],[126.79249108209785,37.223456121602794],[126.65845787892806,37.254299221032284],[126.70289147196388,37.12470123857133],[126.75521894609756,37.048814195119064],[126.85865319059235,37.04132721587837],[126.83790123815163,36.9758975278487],[126.96168866400069,36.90511165128174],[127.13087609255774,36.92580841682502],[127.25996382127335,36.87893789306935],[127.38057661332016,36.91480133731193],[127.46739301957223,37.00957591449691],[127.51772586485686,37.01427846947581],[127.60702273980769,37.131351427426466],[127.83109175038408,37.508486030505935],[127.61632449787737,37.5930286727351],[127.53974002478242,37.60457835550676],[127.55648318894805,37.6777521839162],[127.53787967280869,37.79164704027875],[127.62583296062269,37.88109894486047],[127.50449669816419,37.948846746979825],[127.45168338418091,38.06095876753312],[127.29675743970415,38.0836188835047],[127.17676476528175,38.07380036149755],[127.08230024555996,38.11994741484136],[126.96606463557453,38.213038015991685]],[[127.11588992667885,37.5505764840176],[127.19206098772452,37.528329780095305],[127.1446220240872,37.45776561069442],[126.92075971908582,37.429472765555715],[126.84252159959226,37.47750600839561],[126.86877322830196,37.56029165323724],[126.96602827418553,37.61341502558304],[127.02307905515431,37.67720958155701],[127.09025841739208,37.680801093395814],[127.11588992667885,37.5505764840176]]],[[[126.50782311344597,37.59686920774777],[126.52149498760366,37.75116608274857],[126.42530358159513,37.824448960251345],[126.3696395192485,37.78534577039102],[126.4101668629105,37.60203685072014],[126.50782311344597,37.59686920774777]]]]},"properties":{"shapeName":"Gyeonggi","shapeISO":"KR-41","shapeID":"68945753B43731078897969","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[126.40541386495795,36.45883552803724],[126.36024360036197,36.60936012297134],[126.3148181117665,36.58178686317325],[126.34262129044032,36.438381252093166],[126.40541386495795,36.45883552803724]]],[[[126.96168866400069,36.90511165128174],[126.86019941477127,36.869574285924614],[126.79623457126854,36.95575592729426],[126.62609486678468,36.996104465049086],[126.5190535818574,37.05540599189305],[126.36378014383865,37.00145091392858],[126.41553795681136,36.899847723293874],[126.33936608187923,36.85700104426286],[126.22999108232261,36.87303294541414],[126.19223066481334,36.80857982040243],[126.2031356128183,36.705877997271784],[126.42423834362467,36.60658136983443],[126.47032852114444,36.54019879343963],[126.4987793762673,36.42010585899823],[126.57154381619421,36.305609441622494],[126.53492272190613,36.21963125208066],[126.69247480636727,35.99990468994952],[126.81208373737468,36.047303686815724],[126.85709435432102,36.09221588825267],[127.06473025909429,36.13262685784326],[127.11402957560449,36.05570648886345],[127.33840864544266,36.098210354424396],[127.43245975131651,35.98708018670129],[127.57105594277755,35.99299713850718],[127.61901167215103,36.02950653699713],[127.57591352828672,36.08743581800866],[127.55834354092178,36.186034451029116],[127.46573937407288,36.203604438394166],[127.48227583176424,36.374498398882054],[127.44982303310655,36.29078257895293],[127.31658717156074,36.23320992409879],[127.29069099719186,36.36010118012541],[127.21898978704905,36.45624225107906],[127.15082319583394,36.441238512447455],[127.08405724474608,36.48828990335642],[127.08093198192546,36.639779866547656],[127.10423853957695,36.69157221618491],[127.2465279481064,36.64603221331606],[127.33933882142958,36.764810491810806],[127.25996382127335,36.87893789306935],[127.13087609255774,36.92580841682502],[126.96168866400069,36.90511165128174]]]]},"properties":{"shapeName":"South Chungcheong","shapeISO":"KR-44","shapeID":"68945753B70752081047384","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[126.60379237162067,37.549296673202946],[126.59598521574515,37.43965658858076],[126.72004509120802,37.377053381608334],[126.79580610636674,37.4430378281333],[126.70061811713242,37.55202342394193],[126.60379237162067,37.549296673202946]]],[[[126.56330320687755,37.51444329262392],[126.50066081734292,37.5301852586428],[126.35551114112627,37.46622280014219],[126.44341265317144,37.42062538095661],[126.56330320687755,37.51444329262392]]]]},"properties":{"shapeName":"Incheon","shapeISO":"KR-28","shapeID":"68945753B50642023031709","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[126.81208373737468,36.047303686815724],[126.7444767591951,35.98956940310552],[126.60880930260726,35.9311267638891],[126.61228893158898,35.89022422037152],[126.73316491023252,35.8855654965505],[126.59716770102864,35.7138901942771],[126.46755899055506,35.639638446557115],[126.5131577812067,35.57689651825979],[126.41822350421137,35.39402903890908],[126.43690019998428,35.37056205659371],[126.58372602813381,35.391845200795444],[126.57008344939175,35.3012305771295],[126.75136437356707,35.33978119654506],[126.79125858011946,35.430214952158394],[126.86691287632823,35.462021796568706],[126.98008426317824,35.382595120468466],[127.0368249857845,35.42838043950576],[127.12674197835963,35.28443573701969],[127.26120405472341,35.301488959547896],[127.43101281139218,35.29097280555061],[127.4964351753431,35.3331149359056],[127.61746137943919,35.276632594818466],[127.68825809103816,35.38636749946056],[127.62914025342002,35.52217316296117],[127.70655154791564,35.73482168197512],[127.76980350063116,35.8026728377813],[127.86757531135163,35.83419546225082],[127.91387739522565,35.88706045217816],[127.87760053893373,36.0073631858624],[127.75368045498908,35.975220445567174],[127.61901167215103,36.02950653699713],[127.57105594277755,35.99299713850718],[127.43245975131651,35.98708018670129],[127.33840864544266,36.098210354424396],[127.11402957560449,36.05570648886345],[127.06473025909429,36.13262685784326],[126.85709435432102,36.09221588825267],[126.81208373737468,36.047303686815724]]]},"properties":{"shapeName":"North Jeolla","shapeISO":"KR-45","shapeID":"68945753B82824634329413","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[126.43690019998428,35.37056205659371],[126.36158287884689,35.26902903915891],[126.36353600398343,35.187974350935576],[126.45028730561478,35.066595769978846],[126.37411543068265,35.0168317728681],[126.41098066482584,34.85126373873061],[126.36401690835373,34.7491601077528],[126.29029381630664,34.75023021015613],[126.27108808714559,34.64248281532974],[126.29525800924978,34.589056707800864],[126.46591230580827,34.540676173766485],[126.47958418086534,34.34882233308423],[126.53280683656635,34.292222398445006],[126.60531660197967,34.32168203299949],[126.62964928518647,34.38922760636899],[126.80982506577425,34.45123932473513],[126.88502037858768,34.42108795774311],[126.99415123828908,34.540676173766485],[127.01026451909229,34.612494207641646],[127.14804121233215,34.691473700351025],[127.2907820967165,34.72138092658855],[127.2234806650008,34.54682037975675],[127.31592550435857,34.44490101986622],[127.50075946737559,34.612163866869025],[127.40601647168285,34.700018622261666],[127.38526451924224,34.760199285868396],[127.51099694125594,34.88471100500891],[127.58448326878784,34.746242580230955],[127.63795006614271,34.69822825732825],[127.74984785199683,34.737127997159405],[127.77450605561,34.829331773242814],[127.65650475359155,34.81191640811579],[127.5764266292856,34.89691803643831],[127.6621199882469,34.90680573069994],[127.74439537934336,34.96019114840283],[127.80499042177064,34.95714697475313],[127.78913048718232,35.00729503004163],[127.64691694636008,35.15625234586912],[127.61746137943919,35.276632594818466],[127.4964351753431,35.3331149359056],[127.43101281139218,35.29097280555061],[127.26120405472341,35.301488959547896],[127.12674197835963,35.28443573701969],[127.0368249857845,35.42838043950576],[126.98008426317824,35.382595120468466],[126.86691287632823,35.462021796568706],[126.79125858011946,35.430214952158394],[126.75136437356707,35.33978119654506],[126.57008344939175,35.3012305771295],[126.58372602813381,35.391845200795444],[126.43690019998428,35.37056205659371]],[[126.98556196541244,35.23877960919168],[126.96644168533555,35.15007701344348],[126.8502730649501,35.18488109048997],[126.82288455557784,35.2340770533134],[126.98556196541244,35.23877960919168]]],[[[126.38005618574448,34.49843984572237],[126.30543053499127,34.55426666917151],[126.19629967528988,34.50275299695329],[126.11573326857558,34.43618398570243],[126.20997155034684,34.35879140789723],[126.34587649810192,34.409002996691015],[126.38005618574448,34.49843984572237]]]]},"properties":{"shapeName":"South Jeolla","shapeISO":"KR-46","shapeID":"68945753B5424648896322","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[127.61746137943919,35.276632594818466],[127.64691694636008,35.15625234586912],[127.78913048718232,35.00729503004163],[127.80499042177064,34.95714697475313],[127.89120527420414,34.94668203264973],[128.0192977223121,34.99868398637693],[128.0639754570018,34.92792389517166],[128.20704186269188,34.890692449896676],[128.22413170741243,34.93789297088398],[128.43531334673366,34.84316640760369],[128.46273847739883,35.07196686387971],[128.52637779959582,35.103461005021416],[128.9184195927901,35.08188205311433],[128.96277764277409,35.13338599107067],[129.01096805203406,35.238107814723946],[129.09313358015072,35.27076731985596],[129.19927376777025,35.16363903451389],[129.26883627587347,35.315724062015875],[129.06429812995475,35.46625926445353],[129.0026998227387,35.473959052068665],[129.00848758423467,35.57950816477006],[128.91257612458855,35.59095449475416],[128.7897929222056,35.53018300983808],[128.61491987535203,35.54379975015837],[128.5023686061263,35.597129828079176],[128.42144331325835,35.57927562077333],[128.34031131391612,35.61017812851787],[128.21959516818254,35.61276194910471],[128.21649458185948,35.70510773443442],[128.1243038270601,35.79362946213001],[127.96555382674774,35.8272449807713],[127.91387739522565,35.88706045217816],[127.86757531135163,35.83419546225082],[127.76980350063116,35.8026728377813],[127.70655154791564,35.73482168197512],[127.62914025342002,35.52217316296117],[127.68825809103816,35.38636749946056],[127.61746137943919,35.276632594818466]]],[[[128.06959069075788,34.81500885557426],[127.96192467468404,34.80695221607198],[127.9056095706253,34.86896393443806],[127.82935631604118,34.86896393443806],[127.8680119151179,34.72186920809753],[128.0600692067286,34.70840078306975],[128.06959069075788,34.81500885557426]]],[[[128.7485457692719,34.86896393443806],[128.69385826904397,34.98627350491836],[128.48845462278268,34.86896393443806],[128.61817467472156,34.725572007442224],[128.7485457692719,34.86896393443806]]]]},"properties":{"shapeName":"South Gyeongsang","shapeISO":"KR-48","shapeID":"68945753B56309283475968","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.96277764277409,35.13338599107067],[128.99702610517068,35.07572809487573],[129.12025714924812,35.14961672333317],[129.19927376777025,35.16363903451389],[129.09313358015072,35.27076731985596],[129.01096805203406,35.238107814723946],[128.96277764277409,35.13338599107067]]]},"properties":{"shapeName":"Busan","shapeISO":"KR-26","shapeID":"68945753B18996591190839","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[129.26883627587347,35.315724062015875],[129.36044355592549,35.35358307489906],[129.35775800942463,35.451808985696175],[129.4546004571455,35.513251043800665],[129.45905595576778,35.610784051243],[129.28599002462,35.612684434738924],[129.24464887904264,35.66405080789849],[129.09861128058617,35.66591115987217],[129.00848758423467,35.57950816477006],[129.0026998227387,35.473959052068665],[129.06429812995475,35.46625926445353],[129.26883627587347,35.315724062015875]]]},"properties":{"shapeName":"Ulsan","shapeISO":"KR-31","shapeID":"68945753B85001375391280","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.65026655475776,37.022262477931065],[128.46505822195945,36.9104346791172],[128.42537072188134,36.78896922544942],[128.34165490105295,36.760753892877915],[128.23788862506,36.83188650216039],[128.20243859286688,36.77630849953766],[128.0571244648222,36.77574005785743],[128.02580854682708,36.71693227770248],[127.86271772674172,36.640709539813486],[127.79998253796384,36.541981716483065],[127.88814253135286,36.479401557336075],[127.83088504390969,36.292229518877264],[127.94901736905638,36.21944326409539],[127.9682410010214,36.09456716574215],[127.87760053893373,36.0073631858624],[127.91387739522565,35.88706045217816],[127.96555382674774,35.8272449807713],[128.1243038270601,35.79362946213001],[128.21649458185948,35.70510773443442],[128.21959516818254,35.61276194910471],[128.34031131391612,35.61017812851787],[128.42144331325835,35.57927562077333],[128.5023686061263,35.597129828079176],[128.61491987535203,35.54379975015837],[128.7897929222056,35.53018300983808],[128.91257612458855,35.59095449475416],[129.00848758423467,35.57950816477006],[129.09861128058617,35.66591115987217],[129.24464887904264,35.66405080789849],[129.28599002462,35.612684434738924],[129.45905595576778,35.610784051243],[129.53558955331528,35.939035632188734],[129.5872501963396,36.01691315321949],[129.5425724616498,36.06590403914015],[129.43305147514548,35.99610773158662],[129.3823348315873,36.20184967672128],[129.38656660226695,36.32269928634304],[129.44092858208842,36.407945053621404],[129.42261803539407,36.61664459826966],[129.34717492068614,36.61174490020704],[129.28898725815554,36.656083279319944],[129.28609337830687,36.81956167303281],[129.16837446520947,36.84162750890249],[129.09809451664864,36.918728745934914],[129.06099083895617,37.02451040353236],[128.8038489120977,37.09287832327607],[128.75599653461245,36.99603668944172],[128.65026655475776,37.022262477931065]],[[128.7413204288947,35.84881989202444],[128.68871382138582,35.78812592237347],[128.4812846212883,35.82773590808574],[128.57667931519825,35.99961172140388],[128.69315799394633,36.0093268915229],[128.74597130703023,35.96757233299684],[128.7413204288947,35.84881989202444]]]},"properties":{"shapeName":"North Gyeongsang","shapeISO":"KR-47","shapeID":"68945753B22494711915469","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[126.94605553483518,33.47801341388799],[126.7923603296656,33.56379690470345],[126.4849345876612,33.52075755132],[126.31056732672084,33.46447646141962],[126.20622806117615,33.37555573061252],[126.17139733182148,33.269354559065675],[126.28614342527896,33.20038483279387],[126.33342729633273,33.23816904366646],[126.47056220458217,33.22871389542996],[126.84058678465249,33.31020742403342],[126.93921959730665,33.43085358272674],[126.94605553483518,33.47801341388799]]]},"properties":{"shapeName":"Jeju","shapeISO":"KR-49","shapeID":"68945753B53856725499604","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.11588992667885,37.5505764840176],[127.09025841739208,37.680801093395814],[127.02307905515431,37.67720958155701],[126.96602827418553,37.61341502558304],[126.86877322830196,37.56029165323724],[126.84252159959226,37.47750600839561],[126.92075971908582,37.429472765555715],[127.1446220240872,37.45776561069442],[127.19206098772452,37.528329780095305],[127.11588992667885,37.5505764840176]]]},"properties":{"shapeName":"Seoul","shapeISO":"KR-11","shapeID":"68945753B55100681051852","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.48227583176424,36.374498398882054],[127.35256798722287,36.47906566055184],[127.21898978704905,36.45624225107906],[127.29069099719186,36.36010118012541],[127.31658717156074,36.23320992409879],[127.44982303310655,36.29078257895293],[127.48227583176424,36.374498398882054]]]},"properties":{"shapeName":"Daejeon","shapeISO":"KR-30","shapeID":"68945753B85435209225479","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.21898978704905,36.45624225107906],[127.35256798722287,36.47906566055184],[127.36135298135503,36.54366120130345],[127.2116980326382,36.535651353527214],[127.2465279481064,36.64603221331606],[127.10423853957695,36.69157221618491],[127.08093198192546,36.639779866547656],[127.08405724474608,36.48828990335642],[127.15082319583394,36.441238512447455],[127.21898978704905,36.45624225107906]]]},"properties":{"shapeName":"Sejong","shapeISO":"KR-50","shapeID":"68945753B58129180850627","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.60702273980769,37.131351427426466],[127.51772586485686,37.01427846947581],[127.46739301957223,37.00957591449691],[127.38057661332016,36.91480133731193],[127.25996382127335,36.87893789306935],[127.33933882142958,36.764810491810806],[127.2465279481064,36.64603221331606],[127.2116980326382,36.535651353527214],[127.36135298135503,36.54366120130345],[127.35256798722287,36.47906566055184],[127.48227583176424,36.374498398882054],[127.46573937407288,36.203604438394166],[127.55834354092178,36.186034451029116],[127.57591352828672,36.08743581800866],[127.61901167215103,36.02950653699713],[127.75368045498908,35.975220445567174],[127.87760053893373,36.0073631858624],[127.9682410010214,36.09456716574215],[127.94901736905638,36.21944326409539],[127.83088504390969,36.292229518877264],[127.88814253135286,36.479401557336075],[127.79998253796384,36.541981716483065],[127.86271772674172,36.640709539813486],[128.02580854682708,36.71693227770248],[128.0571244648222,36.77574005785743],[128.20243859286688,36.77630849953766],[128.23788862506,36.83188650216039],[128.34165490105295,36.760753892877915],[128.42537072188134,36.78896922544942],[128.46505822195945,36.9104346791172],[128.65026655475776,37.022262477931065],[128.50836307229804,37.07414561592748],[128.42071984284655,37.063732814717696],[128.32191450425103,37.17543142322165],[128.19106977814783,37.202794094172134],[128.03800418474543,37.150807603388216],[127.9875679866733,37.218271186466154],[127.91770145106113,37.1860250924841],[127.60702273980769,37.131351427426466]]]},"properties":{"shapeName":"North Chungcheong","shapeISO":"KR-43","shapeID":"68945753B59172752195743","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[126.98556196541244,35.23877960919168],[126.82288455557784,35.2340770533134],[126.8502730649501,35.18488109048997],[126.96644168533555,35.15007701344348],[126.98556196541244,35.23877960919168]]]},"properties":{"shapeName":"Gwangju","shapeISO":"KR-29","shapeID":"68945753B46109248456415","shapeGroup":"KOR","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.7413204288947,35.84881989202444],[128.74597130703023,35.96757233299684],[128.69315799394633,36.0093268915229],[128.57667931519825,35.99961172140388],[128.4812846212883,35.82773590808574],[128.68871382138582,35.78812592237347],[128.7413204288947,35.84881989202444]]]},"properties":{"shapeName":"Daegu","shapeISO":"KR-27","shapeID":"68945753B94077674833362","shapeGroup":"KOR","shapeType":"ADM1"}}
|
||||||
|
]}
|
||||||
13
public/maps/prk-adm1.geojson
Normal file
13
public/maps/prk-adm1.geojson
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{"type":"FeatureCollection", "features": [
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.06059889200003,41.658746516000065],[127.04865335200009,41.73465184500003],[126.99965523300011,41.746406155000045],[126.93248868400008,41.80865161000003],[126.77060662600002,41.71317649200006],[126.72522647700009,41.706844225000054],[126.59030075200008,41.617252810000025],[126.58679083000004,41.562279311000054],[126.5017407040001,41.43233361500006],[126.48520609000002,41.37076591500005],[126.43216763100008,41.35158903700005],[126.31897591900008,41.22978622900007],[126.26979117200005,41.15317694100003],[126.22994111500009,41.13992305700003],[126.13067776900004,41.05520689700006],[126.04945516100008,40.94698866600004],[125.9641038100001,40.896193022000034],[125.90067310100005,40.89857990000007],[125.80806616500001,40.86182394400004],[125.70765685600009,40.86745226100004],[125.64089303100002,40.815222525000024],[125.67138547900004,40.76138189300008],[125.54763125900001,40.75915872400003],[125.4832084730001,40.721135659000026],[125.41125743000009,40.65439344300006],[125.49859619100005,40.61554718000008],[125.53858947800006,40.57332611100003],[125.59275817900004,40.57332611100003],[125.61387634300002,40.51998901400003],[125.58167266800001,40.408599854000045],[125.63053894000006,40.36998748800005],[125.61943054200003,40.32804870600006],[125.79553222700008,40.22665405300006],[125.88192749000007,40.23860168500005],[125.98165893600003,40.216102600000056],[126.141372681,40.07471466100003],[126.21914672900004,40.04222106900005],[126.36020660400004,40.017616272000055],[126.44664001500007,39.99748992900004],[126.56218719500009,40.04694366500007],[126.5697174070001,40.08888244600007],[126.72915649400011,40.180824280000024],[126.81414794900002,40.212768555000025],[126.82666015600012,40.25193786600005],[126.88997650100009,40.294441223000035],[126.91118621800001,40.334693909000066],[126.93247985800008,40.40693664600008],[127.00776672400002,40.48165893600003],[127.00610351600005,40.54804992700008],[127.12359619100005,40.615272522000055],[127.20583343500005,40.62860107400007],[127.24803161600005,40.72470855700004],[127.40525817900004,40.79415893600003],[127.41776275600012,40.820549011000026],[127.3963775630001,40.92360687300004],[127.40749359100005,40.96415710400004],[127.4871978760001,41.01444244400005],[127.50563049300001,41.07077789300007],[127.50026702900004,41.14221954300007],[127.44859314000007,41.16971588100006],[127.32499694800003,41.099998474000074],[127.24165344200003,41.107772827000076],[127.14888000500002,41.15138244600007],[127.12303161600005,41.23693847700008],[127.06944274900002,41.293884277000075],[127.01638793900008,41.38416290300006],[127.03333282500012,41.42416381800007],[126.93081665000011,41.462768555000025],[126.92970275900007,41.53527069100005],[127.00054931600005,41.53610992400007],[127.06414794900002,41.58693695100004],[127.06059889200003,41.658746516000065]]]},"properties":{"shapeName":"Jagang","shapeISO":"KP05","shapeID":"3916807B5646338397672","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.33414895400006,39.40514759700005],[127.281097412,39.413047791000054],[127.12442016600005,39.40082550000005],[127.09108734100005,39.28971862800006],[127.0374908450001,39.27249145500008],[127.02551269500009,39.20433807400008],[127.06553649900002,39.166938782000045],[126.9891510010001,39.11193847700008],[126.9820098880001,39.06920623800005],[127.01914978000002,38.98249054000007],[126.96555328400007,38.90832519500003],[126.87359619100005,38.879158020000034],[126.86997985800008,38.78249359100005],[126.80081176800002,38.68554687500006],[126.70082092300004,38.64360809300007],[126.68221283000003,38.607772827000076],[126.71527099600007,38.56888580300006],[126.69999694800003,38.513328552000075],[126.73248291000004,38.430274963000045],[126.79498291000004,38.348045349000074],[126.88466644300001,38.27037048300008],[126.86943054200003,38.23526763900003],[126.9613189700001,38.162044525000056],[127.01658630400004,38.237419128000056],[127.1323471070001,38.30494308500005],[127.23873901400009,38.32726669300007],[127.2918090820001,38.31295013400006],[127.40125274700006,38.33113861100003],[127.4989166260001,38.29781723000008],[127.58606720000012,38.33311080900006],[127.68184661900011,38.31896591200007],[127.79717254600007,38.336143494000055],[127.82942962600009,38.30079269400005],[127.89333343500005,38.32870101900005],[128.0469512940001,38.30499267600004],[128.13771057100007,38.32830047600004],[128.2891845700001,38.43442535400004],[128.3206939700001,38.51089859000007],[128.31510925300006,38.58634567300004],[128.359923352,38.612441567000076],[128.3560338320001,38.67887507300003],[128.28311127300003,38.721743925000055],[128.18505937400005,38.748369565000075],[128.09101834700004,38.83505023300006],[128.01023775300007,38.850307917000066],[127.85227161500006,38.977018722000025],[127.74484901000005,39.12734946900008],[127.65830913200011,39.10499663500008],[127.6213370800001,39.131952968000064],[127.444298838,39.152442793000034],[127.3744736540001,39.20470199600004],[127.36798750100002,39.27591543300008],[127.46623257700003,39.353962086000024],[127.37965995600007,39.350655163000056],[127.41194010700008,39.40752768700003],[127.33414895400006,39.40514759700005]]]},"properties":{"shapeName":"Kangwon","shapeISO":"KP04","shapeID":"3916807B60771411413787","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[125.24655558100005,38.65766876200007],[125.16577090400006,38.618168524000055],[125.2259674070001,38.61454772900004],[125.24655558100005,38.65766876200007]]],[[[125.5788650510001,39.010272980000025],[125.52272033700001,39.00202560400004],[125.36661529500009,39.02656936600005],[125.35798645000011,38.95692062400008],[125.25514221200001,38.95952224700005],[125.21818358500002,38.99453788400007],[125.15097077700011,38.87984430400007],[125.13671171900012,38.805720120000046],[125.21810785900004,38.75064512100005],[125.28224251600011,38.687554927000065],[125.43359480800007,38.73125542100007],[125.529053556,38.71763482800003],[125.5581390540001,38.691945909000026],[125.62979126000005,38.65693664600008],[125.61804199200003,38.71972656200006],[125.57998657200005,38.74721527100007],[125.5269317630001,38.85832977300004],[125.58776855500003,38.88443756100003],[125.60594177200005,38.93884658800005],[125.5788650510001,39.010272980000025]]]]},"properties":{"shapeName":"Nampo","shapeISO":"KP11","shapeID":"3916807B42842403076429","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[129.16803093900012,40.56774067300006],[129.2206435390001,40.698969454000064],[129.27329891800002,40.70472219000004],[129.41259895000007,40.769714683000075],[129.51502842900004,40.78967409100005],[129.56140065400007,40.83030995100006],[129.73391725200008,40.854633842000055],[129.76510710000002,40.93834810200008],[129.72071902800008,41.01678110900008],[129.71792615200002,41.12276962200008],[129.73458845900007,41.24319727400007],[129.7996529840001,41.368742471000076],[129.66038569500006,41.46642766200006],[129.65017679800007,41.549454344000026],[129.70710825800006,41.66615270400007],[129.74023314700003,41.68414760300004],[129.75949188000004,41.75555749100005],[129.86857449500008,41.79212681800004],[129.8968666930001,41.90225062300004],[129.96710115000008,41.92067443900004],[129.97912721700004,41.97919387700006],[130.08071850500005,42.04546330000005],[130.07577449200005,42.07540896000006],[130.19308512400005,42.10683127500005],[130.23236219900002,42.17739642400005],[130.28175322000004,42.21467297500004],[130.36909527300008,42.234985994000056],[130.4020400820001,42.330516774000046],[130.48671040300007,42.33713389400003],[130.57634110800007,42.31301627600004],[130.62147569400008,42.272049723000066],[130.67486448900002,42.283614131000036],[130.64503342600005,42.348295577000044],[130.65554682100003,42.38646270500004],[130.56109652200007,42.45064647500004],[130.5456227420001,42.52771742200008],[130.51135188900003,42.58123063700003],[130.44197199400003,42.54782089400004],[130.39428682100004,42.600731659000076],[130.25247221900008,42.70788578100007],[130.2273854440001,42.787759427000026],[130.268492787,42.88032379900005],[130.16943244000004,42.914260685000045],[130.1163186760001,42.955567753000025],[129.8825225060001,42.99543090600008],[129.8548884180001,42.965082523000035],[129.8367167130001,42.86390378100003],[129.76007141000002,42.70881892300008],[129.74705654100012,42.64695982100005],[129.77714626400007,42.60405822700005],[129.736327049,42.57342826400003],[129.74374306900006,42.46782610300005],[129.70098881400008,42.42625382700004],[129.60684301300012,42.43712738200003],[129.54037386400012,42.367966856000066],[129.451705768,42.437369671000056],[129.30557252100004,42.416958427000054],[129.24920749400007,42.33726158700006],[129.20370620100005,42.20621576600007],[129.16528368700006,42.164273507000075],[129.01247242400007,42.09948205400008],[129.01666259800004,42.054161072000056],[128.96606445300006,41.98860168500005],[128.90136718700012,41.974433899000076],[128.83828735400004,41.89638519300007],[128.87411499000007,41.82555389400005],[128.9260864260001,41.82193756100003],[128.92996215800008,41.72248840300006],[128.99191284200003,41.62721252400007],[129.04971313500005,41.57193756100003],[129.0510864260001,41.52943420400004],[128.97662353500004,41.46832275400004],[128.9724731450001,41.36998748800005],[129.02859497100008,41.288047791000054],[128.99383544900002,41.21249389600007],[129.00692749000007,41.124710083000025],[128.98876953100012,41.098930359000065],[129.00027465800008,41.00666046100008],[128.93914794900002,40.92276763900003],[128.9591369630001,40.87832641600005],[128.93801879900002,40.825828552000075],[128.9741210940001,40.73693847700008],[129.05801391600005,40.656654358000026],[129.06359863300008,40.59748840300006],[129.16803093900012,40.56774067300006]]]},"properties":{"shapeName":"North Hamgyong","shapeISO":"KP02","shapeID":"3916807B66197610677259","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[126.9820098880001,39.06920623800005],[126.93193054200003,39.05832672100007],[126.8394317630001,39.106941223000035],[126.73027038600003,39.14694213900003],[126.65998840300006,39.13555145300006],[126.56192016600005,39.15943145800003],[126.56275939900002,39.11499786400003],[126.66110229500009,38.985824585000046],[126.624153137,38.96665954600007],[126.50277710000012,39.00332641600005],[126.50054931600005,38.930824280000024],[126.46832275400004,38.90776825000006],[126.38739013700001,38.93048095700004],[126.19941711400008,38.98749542200005],[126.09883117700008,38.95777511600005],[126.05368804900002,39.000923157000045],[125.95604705800008,39.00920105000006],[125.96190643300008,38.947296143000074],[125.92891693100012,38.909690857000044],[125.86034393300008,38.89368820200008],[125.79225921600005,38.923427582000045],[125.74641418500005,38.90199661300005],[125.71104431200001,38.945125580000024],[125.60594177200005,38.93884658800005],[125.58776855500003,38.88443756100003],[125.5269317630001,38.85832977300004],[125.57998657200005,38.74721527100007],[125.61804199200003,38.71972656200006],[125.62979126000005,38.65693664600008],[125.65332031200012,38.62721252400007],[125.6624908450001,38.53193664600008],[125.69664001500007,38.49193573000008],[125.69941711400008,38.41554260300006],[125.745246887,38.343048096000075],[125.90165710400004,38.24499511700003],[125.936653137,38.169441223000035],[126.04525756800001,38.19193267800006],[126.08083343500005,38.175827026000036],[126.20166015600012,38.231933594000054],[126.27221679700006,38.24137878400006],[126.40637207000009,38.19026947000003],[126.3783111570001,38.12748718300003],[126.39026641800001,37.925270081000065],[126.41304016100003,37.908075653000026],[126.42301956900008,37.85167615800003],[126.59584066600007,37.77005737400003],[126.64972058900003,37.800909982000064],[126.68923950200008,37.85894775400004],[126.68286895800009,37.945335388000046],[126.7839965820001,37.96795654300007],[126.86341857900004,38.04615402200005],[126.8922424320001,38.09919738800005],[126.9613189700001,38.162044525000056],[126.86943054200003,38.23526763900003],[126.88466644300001,38.27037048300008],[126.79498291000004,38.348045349000074],[126.73248291000004,38.430274963000045],[126.69999694800003,38.513328552000075],[126.71527099600007,38.56888580300006],[126.68221283000003,38.607772827000076],[126.70082092300004,38.64360809300007],[126.80081176800002,38.68554687500006],[126.86997985800008,38.78249359100005],[126.87359619100005,38.879158020000034],[126.96555328400007,38.90832519500003],[127.01914978000002,38.98249054000007],[126.9820098880001,39.06920623800005]]]},"properties":{"shapeName":"North Hwanghae","shapeISO":"KP08","shapeID":"3916807B54689859523154","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[124.89328618200011,39.606712489000074],[124.84665529600011,39.549758111000074],[124.87832644900004,39.51045484200006],[124.9398483110001,39.54305258800008],[124.89328618200011,39.606712489000074]]],[[[124.25144847800004,39.80633930600004],[124.28198349000002,39.83034560100003],[124.2722689950001,39.920821045000025],[124.2260964940001,39.890004453000074],[124.25144847800004,39.80633930600004]]],[[[125.41125743000009,40.65439344300006],[125.31999054900007,40.63847896000004],[125.15878297300003,40.59219513700003],[125.02278986400006,40.53346942600007],[124.9924316580001,40.468306676000054],[124.90529587800006,40.46259323000004],[124.73451132200012,40.36302538500007],[124.70905456500009,40.31465263500007],[124.61437834700007,40.285977357000036],[124.52159133100008,40.22936021900006],[124.48384984600011,40.17534932900003],[124.40371754000012,40.13402916500007],[124.33735643900002,40.064940671000045],[124.36537033200011,40.02654107600006],[124.3800419370001,39.94931951700005],[124.3158810860001,39.90345806300007],[124.45848149200003,39.83274230200004],[124.55713584000011,39.796009265000066],[124.57214468600012,39.71877788300003],[124.63884630100006,39.691071763000025],[124.6137464300001,39.60944970500003],[124.63941928300005,39.58972603800004],[124.75669717300002,39.664328253000065],[124.77011804100005,39.73073191800006],[124.89205181600005,39.685977138000055],[124.92771092200007,39.65165389800006],[125.10368505500003,39.61327722200008],[125.12412904100006,39.56630254700008],[125.18242583400001,39.593563378000056],[125.31305583400001,39.526514700000064],[125.39354830000002,39.57505443300005],[125.43446104400005,39.57099987300006],[125.50388336200001,39.57555389400005],[125.63471984900002,39.636383057000046],[125.67221069300001,39.629158020000034],[125.82360839800003,39.690544128000056],[125.85498046900011,39.74582672100007],[125.8986129760001,39.75054931600005],[125.99942016600005,39.82943725600006],[126.10054016100003,39.81971740700004],[126.27110290500002,39.85416412400008],[126.36720275900007,39.893882751000035],[126.3394317630001,39.96832275400004],[126.36020660400004,40.017616272000055],[126.21914672900004,40.04222106900005],[126.141372681,40.07471466100003],[125.98165893600003,40.216102600000056],[125.88192749000007,40.23860168500005],[125.79553222700008,40.22665405300006],[125.61943054200003,40.32804870600006],[125.63053894000006,40.36998748800005],[125.58167266800001,40.408599854000045],[125.61387634300002,40.51998901400003],[125.59275817900004,40.57332611100003],[125.53858947800006,40.57332611100003],[125.49859619100005,40.61554718000008],[125.41125743000009,40.65439344300006]]]]},"properties":{"shapeName":"North Pyongan","shapeISO":"KP06","shapeID":"3916807B80386364257881","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[125.60594177200005,38.93884658800005],[125.71104431200001,38.945125580000024],[125.74641418500005,38.90199661300005],[125.79225921600005,38.923427582000045],[125.86034393300008,38.89368820200008],[125.92891693100012,38.909690857000044],[125.96190643300008,38.947296143000074],[125.95604705800008,39.00920105000006],[126.05368804900002,39.000923157000045],[126.09883117700008,38.95777511600005],[126.19941711400008,38.98749542200005],[126.38739013700001,38.93048095700004],[126.38942718500005,38.96110534700006],[126.33719635000011,39.05304718000008],[126.27110290500002,39.067497253000056],[126.23552703900009,39.155548096000075],[126.0963668820001,39.18110656700003],[126.07360839800003,39.223602295000035],[125.97026062000009,39.20332336400003],[125.92498779300001,39.23471832300004],[125.82704925500002,39.22008132900004],[125.7130432130001,39.28137970000006],[125.63081359900002,39.24748992900004],[125.63388061500007,39.14694213900003],[125.66081237800006,39.117767334000064],[125.57859802200005,39.06526947000003],[125.5788650510001,39.010272980000025],[125.60594177200005,38.93884658800005]]]},"properties":{"shapeName":"Pyongyang","shapeISO":"KP10","shapeID":"3916807B24987303688366","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.50563049300001,41.07077789300007],[127.58167266800001,41.06137847900004],[127.62497711200001,41.083877563000044],[127.66304016100003,41.14638519300007],[127.79386901900011,41.11166381800007],[127.77026367200006,41.02027130100004],[127.69026184100005,40.98888397200005],[127.71692657500012,40.944992065000065],[127.59554290800008,40.85027313200004],[127.62275695800008,40.769714355000076],[127.6733093260001,40.699996948000035],[127.71164703400007,40.680824280000024],[127.72387695300006,40.608886719000054],[127.7869262700001,40.60027313200004],[127.76470947300004,40.51499939000007],[127.7252731320001,40.45943450900006],[127.74275207500011,40.41387939500004],[127.9119262700001,40.49777221700003],[127.98636627200005,40.58193969700005],[128.09661865200007,40.601387024000076],[128.18801879900002,40.56888580300006],[128.32995605500003,40.68526458700006],[128.39080810500002,40.79610443100006],[128.43856811500007,40.90998840300006],[128.4374694820001,40.999160767000035],[128.57553100600012,41.013328552000075],[128.6713562010001,41.12054443400007],[128.74328613300008,41.17471313500005],[128.842468262,41.17804718000008],[128.967468262,41.12193298300008],[128.98876953100012,41.098930359000065],[129.00692749000007,41.124710083000025],[128.99383544900002,41.21249389600007],[129.02859497100008,41.288047791000054],[128.9724731450001,41.36998748800005],[128.97662353500004,41.46832275400004],[129.0510864260001,41.52943420400004],[129.04971313500005,41.57193756100003],[128.99191284200003,41.62721252400007],[128.92996215800008,41.72248840300006],[128.9260864260001,41.82193756100003],[128.87411499000007,41.82555389400005],[128.83828735400004,41.89638519300007],[128.90136718700012,41.974433899000076],[128.96606445300006,41.98860168500005],[129.01666259800004,42.054161072000056],[129.01247242400007,42.09948205400008],[128.96438648800006,42.09422240400005],[128.9295492020001,42.01846440400004],[128.8357995770001,42.02374565800005],[128.74526847200002,42.05026325000006],[128.68498097600002,42.012204170000075],[128.63682432200005,42.035598193000055],[128.57655319800006,41.99996200700008],[128.488936114,41.99741142000005],[128.42428741000003,42.03186562700006],[128.2618258220001,42.03531661300008],[128.15147282500004,42.00345555800004],[128.0663768600001,41.96184071700003],[128.0984802050001,41.89702830400006],[128.1076970250001,41.78933394200004],[128.15066410200006,41.72653187600008],[128.2427209760001,41.67818531900008],[128.311996098,41.58622994500007],[128.20184282600007,41.40856306100005],[128.12051544300004,41.37806079000006],[128.03866419200006,41.38888195900006],[127.96582021300003,41.43539824800007],[127.85475999300002,41.40498438100008],[127.79911201200002,41.42178420400006],[127.55435061000003,41.43215026100006],[127.49730782900008,41.474803017000056],[127.44255042500004,41.457092972000055],[127.3375474400001,41.46289482000003],[127.24044178000008,41.497100190000026],[127.20924538300005,41.52946546900006],[127.1094450710001,41.53613825000008],[127.14823102000003,41.58608260700004],[127.06059889200003,41.658746516000065],[127.06414794900002,41.58693695100004],[127.00054931600005,41.53610992400007],[126.92970275900007,41.53527069100005],[126.93081665000011,41.462768555000025],[127.03333282500012,41.42416381800007],[127.01638793900008,41.38416290300006],[127.06944274900002,41.293884277000075],[127.12303161600005,41.23693847700008],[127.14888000500002,41.15138244600007],[127.24165344200003,41.107772827000076],[127.32499694800003,41.099998474000074],[127.44859314000007,41.16971588100006],[127.50026702900004,41.14221954300007],[127.50563049300001,41.07077789300007]]]},"properties":{"shapeName":"Ryanggang","shapeISO":"KP01","shapeID":"3916807B2118550659035","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[126.91118621800001,40.334693909000066],[127.0527572630001,40.32527160600006],[127.10192871100003,40.289436340000066],[127.11026001000005,40.188041687000066],[127.17858886700003,40.13555145300006],[127.08442688000002,40.06610107400007],[127.05637359600007,40.02027130100004],[127.0691528320001,39.96305084200003],[127.11192321800002,39.90138244600007],[127.09609985400004,39.85332489000007],[127.03414917000009,39.84526825000006],[126.87081909200003,39.86610412600004],[126.77609252900004,39.83554840100004],[126.71582031200012,39.72665405300006],[126.75388336200001,39.70054626500007],[126.70220947300004,39.65804290800003],[126.71138000500002,39.59054565400004],[126.67886352500011,39.518325806000064],[126.74108886700003,39.46832275400004],[126.69553375200007,39.35582733200005],[126.72276306200001,39.29972076400003],[126.79582214400011,39.31526947000003],[126.84499359100005,39.27332305900006],[126.90971374500009,39.285552979000045],[126.92082214400011,39.224433899000076],[127.02551269500009,39.20433807400008],[127.0374908450001,39.27249145500008],[127.09108734100005,39.28971862800006],[127.12442016600005,39.40082550000005],[127.281097412,39.413047791000054],[127.33414895400006,39.40514759700005],[127.50184584400006,39.42718914400007],[127.5324921780001,39.38950659500006],[127.51658620600006,39.29980713200007],[127.56646835400011,39.30657813800008],[127.51873079500001,39.44425090100003],[127.51450382700011,39.50599540700006],[127.5754396100001,39.585662780000064],[127.48802880000005,39.662812307000024],[127.49035019400003,39.699136071000055],[127.54139795000003,39.77455683100004],[127.63583515300002,39.82327337000004],[127.77612398700012,39.84740081000007],[127.87110797900004,39.900963137000076],[127.88522297300005,39.96682656300004],[127.9587938200001,40.005298190000076],[128.0611872830001,40.01448881600004],[128.16082716000005,40.00741985900004],[128.17381256200008,39.978597243000024],[128.26229730400007,40.006008687000076],[128.32238834800012,40.00000056500005],[128.3605194600001,40.090885281000055],[128.4433889820001,40.10172609500006],[128.5563973400001,40.16849974200005],[128.6624284180001,40.19359306600006],[128.62944104400003,40.305737040000054],[128.6969612050001,40.332300470000064],[128.8091280980001,40.35168362200005],[128.90411536400006,40.39869431300008],[128.95986811800003,40.45118598300007],[129.10217384700002,40.494107222000025],[129.16803093900012,40.56774067300006],[129.06359863300008,40.59748840300006],[129.05801391600005,40.656654358000026],[128.9741210940001,40.73693847700008],[128.93801879900002,40.825828552000075],[128.9591369630001,40.87832641600005],[128.93914794900002,40.92276763900003],[129.00027465800008,41.00666046100008],[128.98876953100012,41.098930359000065],[128.967468262,41.12193298300008],[128.842468262,41.17804718000008],[128.74328613300008,41.17471313500005],[128.6713562010001,41.12054443400007],[128.57553100600012,41.013328552000075],[128.4374694820001,40.999160767000035],[128.43856811500007,40.90998840300006],[128.39080810500002,40.79610443100006],[128.32995605500003,40.68526458700006],[128.18801879900002,40.56888580300006],[128.09661865200007,40.601387024000076],[127.98636627200005,40.58193969700005],[127.9119262700001,40.49777221700003],[127.74275207500011,40.41387939500004],[127.7252731320001,40.45943450900006],[127.76470947300004,40.51499939000007],[127.7869262700001,40.60027313200004],[127.72387695300006,40.608886719000054],[127.71164703400007,40.680824280000024],[127.6733093260001,40.699996948000035],[127.62275695800008,40.769714355000076],[127.59554290800008,40.85027313200004],[127.71692657500012,40.944992065000065],[127.69026184100005,40.98888397200005],[127.77026367200006,41.02027130100004],[127.79386901900011,41.11166381800007],[127.66304016100003,41.14638519300007],[127.62497711200001,41.083877563000044],[127.58167266800001,41.06137847900004],[127.50563049300001,41.07077789300007],[127.4871978760001,41.01444244400005],[127.40749359100005,40.96415710400004],[127.3963775630001,40.92360687300004],[127.41776275600012,40.820549011000026],[127.40525817900004,40.79415893600003],[127.24803161600005,40.72470855700004],[127.20583343500005,40.62860107400007],[127.12359619100005,40.615272522000055],[127.00610351600005,40.54804992700008],[127.00776672400002,40.48165893600003],[126.93247985800008,40.40693664600008],[126.91118621800001,40.334693909000066]]]},"properties":{"shapeName":"South Hamgyong","shapeISO":"KP03","shapeID":"3916807B59695090380014","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[125.16577090400006,38.618168524000055],[125.08627296000009,38.562107955000045],[125.00000156400006,38.58225399100007],[125.00095435100002,38.47492306300006],[124.93723224000007,38.437093176000076],[124.85791847900009,38.343998387000056],[124.87663369800009,38.306639981000046],[124.8662185280001,38.23758750400003],[124.79895047900004,38.16719588600006],[124.69172759900005,38.10892201200005],[124.76248919900002,38.09501329100004],[124.88655119200007,38.12482471000004],[125.11167078200003,38.044417372000055],[125.07569080700011,37.978272367000045],[124.98500908900007,37.94680748800005],[125.03192155500005,37.89732806400008],[125.13170549600011,37.863296227000035],[125.19339106600012,37.86875756100005],[125.24305711800002,37.90211164300007],[125.30152089400008,37.88486325700006],[125.2406244010001,37.81597448800005],[125.43002267700001,37.82514546900006],[125.35994865400005,37.71015623200003],[125.46697180900003,37.72133756000005],[125.4930735800001,37.77747994200007],[125.60563010100009,37.75749434100004],[125.6176823610001,37.85147315900008],[125.68261591900011,37.93409711700008],[125.6000148810001,37.986578964000046],[125.58595882200007,38.03979095400007],[125.69848587500007,37.988559844000065],[125.74305730100002,38.01608915800006],[125.80563344900008,37.989826952000044],[125.79241885400006,37.95732088300008],[125.90183870900012,37.838340419000076],[125.9555451010001,37.828923874000054],[125.97619536000002,37.88989894700006],[126.06205093500012,37.81623969700007],[126.09771658900002,37.75713090700003],[126.16333117600004,37.74094335600006],[126.15292255500003,37.833740195000075],[126.23423684000011,37.857317572000056],[126.26740102000008,37.84046536200003],[126.41304016100003,37.908075653000026],[126.39026641800001,37.925270081000065],[126.3783111570001,38.12748718300003],[126.40637207000009,38.19026947000003],[126.27221679700006,38.24137878400006],[126.20166015600012,38.231933594000054],[126.08083343500005,38.175827026000036],[126.04525756800001,38.19193267800006],[125.936653137,38.169441223000035],[125.90165710400004,38.24499511700003],[125.745246887,38.343048096000075],[125.69941711400008,38.41554260300006],[125.69664001500007,38.49193573000008],[125.6624908450001,38.53193664600008],[125.65332031200012,38.62721252400007],[125.62979126000005,38.65693664600008],[125.5581390540001,38.691945909000026],[125.36253198300005,38.68805915100006],[125.30679232600005,38.64340587000004],[125.24655558100005,38.65766876200007],[125.2259674070001,38.61454772900004],[125.16577090400006,38.618168524000055]]]},"properties":{"shapeName":"South Hwanghae","shapeISO":"KP09","shapeID":"3916807B69422143587516","shapeGroup":"PRK","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[126.36020660400004,40.017616272000055],[126.3394317630001,39.96832275400004],[126.36720275900007,39.893882751000035],[126.27110290500002,39.85416412400008],[126.10054016100003,39.81971740700004],[125.99942016600005,39.82943725600006],[125.8986129760001,39.75054931600005],[125.85498046900011,39.74582672100007],[125.82360839800003,39.690544128000056],[125.67221069300001,39.629158020000034],[125.63471984900002,39.636383057000046],[125.50388336200001,39.57555389400005],[125.43446104400005,39.57099987300006],[125.38701630900005,39.488445797000054],[125.33757945000002,39.45200743600003],[125.41940450700008,39.30980975500006],[125.41265969500012,39.264635879000025],[125.30251297100006,39.17217955600006],[125.21818358500002,38.99453788400007],[125.25514221200001,38.95952224700005],[125.35798645000011,38.95692062400008],[125.36661529500009,39.02656936600005],[125.52272033700001,39.00202560400004],[125.5788650510001,39.010272980000025],[125.57859802200005,39.06526947000003],[125.66081237800006,39.117767334000064],[125.63388061500007,39.14694213900003],[125.63081359900002,39.24748992900004],[125.7130432130001,39.28137970000006],[125.82704925500002,39.22008132900004],[125.92498779300001,39.23471832300004],[125.97026062000009,39.20332336400003],[126.07360839800003,39.223602295000035],[126.0963668820001,39.18110656700003],[126.23552703900009,39.155548096000075],[126.27110290500002,39.067497253000056],[126.33719635000011,39.05304718000008],[126.38942718500005,38.96110534700006],[126.38739013700001,38.93048095700004],[126.46832275400004,38.90776825000006],[126.50054931600005,38.930824280000024],[126.50277710000012,39.00332641600005],[126.624153137,38.96665954600007],[126.66110229500009,38.985824585000046],[126.56275939900002,39.11499786400003],[126.56192016600005,39.15943145800003],[126.65998840300006,39.13555145300006],[126.73027038600003,39.14694213900003],[126.8394317630001,39.106941223000035],[126.93193054200003,39.05832672100007],[126.9820098880001,39.06920623800005],[126.9891510010001,39.11193847700008],[127.06553649900002,39.166938782000045],[127.02551269500009,39.20433807400008],[126.92082214400011,39.224433899000076],[126.90971374500009,39.285552979000045],[126.84499359100005,39.27332305900006],[126.79582214400011,39.31526947000003],[126.72276306200001,39.29972076400003],[126.69553375200007,39.35582733200005],[126.74108886700003,39.46832275400004],[126.67886352500011,39.518325806000064],[126.71138000500002,39.59054565400004],[126.70220947300004,39.65804290800003],[126.75388336200001,39.70054626500007],[126.71582031200012,39.72665405300006],[126.77609252900004,39.83554840100004],[126.87081909200003,39.86610412600004],[127.03414917000009,39.84526825000006],[127.09609985400004,39.85332489000007],[127.11192321800002,39.90138244600007],[127.0691528320001,39.96305084200003],[127.05637359600007,40.02027130100004],[127.08442688000002,40.06610107400007],[127.17858886700003,40.13555145300006],[127.11026001000005,40.188041687000066],[127.10192871100003,40.289436340000066],[127.0527572630001,40.32527160600006],[126.91118621800001,40.334693909000066],[126.88997650100009,40.294441223000035],[126.82666015600012,40.25193786600005],[126.81414794900002,40.212768555000025],[126.72915649400011,40.180824280000024],[126.5697174070001,40.08888244600007],[126.56218719500009,40.04694366500007],[126.44664001500007,39.99748992900004],[126.36020660400004,40.017616272000055]]]},"properties":{"shapeName":"South Pyongan","shapeISO":"KP07","shapeID":"3916807B57216784573597","shapeGroup":"PRK","shapeType":"ADM1"}}
|
||||||
|
]}
|
||||||
85
public/maps/rus-adm1.geojson
Normal file
85
public/maps/rus-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
15
public/maps/sau-adm1.geojson
Normal file
15
public/maps/sau-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
79
public/maps/tha-adm1.geojson
Normal file
79
public/maps/tha-adm1.geojson
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{"type":"FeatureCollection", "features": [
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.5111389,16.1982918],[103.4685974,16.1183281],[103.4972076,15.9266644],[103.4597168,15.8405533],[103.4396973,15.7172203],[103.3544312,15.7019424],[103.298317,15.5955524],[103.3802567,15.5249996],[103.442749,15.5077763],[103.4047897,15.4368992],[103.5391464,15.4386101],[103.6077576,15.4652748],[103.6399765,15.4488869],[103.7752686,15.4349976],[103.8630371,15.4563866],[103.9347076,15.4408321],[103.9828186,15.4033546],[104.0794373,15.5347214],[104.1293182,15.564497],[104.1847076,15.5966644],[104.1615477,15.687995],[104.127903,15.7207166],[104.133954,15.7996039],[104.0774356,15.8386023],[104.0350202,15.796098],[104.0149753,15.8864233],[104.0264435,15.9810123],[104.1085968,15.9819412],[104.1191406,16.0374985],[104.0977631,16.0788841],[104.1788635,16.1424942],[104.2327576,16.1672173],[104.2719269,16.23666],[104.3236084,16.2552757],[104.3559265,16.3233299],[104.3277664,16.3294411],[104.3188782,16.4547157],[104.2440491,16.4328918],[104.17276,16.446106],[104.0469437,16.3966637],[104.0415802,16.4399223],[103.9213715,16.4436073],[103.8722076,16.468605],[103.8035889,16.4572182],[103.7019272,16.3816643],[103.755374,16.2707246],[103.723999,16.2191658],[103.5963669,16.2005501],[103.5566406,16.2183266],[103.5111389,16.1982918]]]},"properties":{"shapeName":"Roi Et Province","shapeISO":"TH-45","shapeID":"36821470B96343765829948","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.6871948,19.4297943],[99.7091522,19.3386078],[99.7060379,19.2403198],[99.7830261,19.0570539],[99.8301,19.0088],[99.9286114,18.969504],[99.9929,18.9086],[100.0397034,18.8983307],[100.1125053,18.8083348],[100.3011715,18.8187216],[100.3401674,18.8066022],[100.4046457,18.9450935],[100.4783957,18.9498201],[100.4485931,19.0005531],[100.4724884,19.0469437],[100.5508118,19.0647202],[100.6085968,19.1052742],[100.6194305,19.1452751],[100.5952606,19.2497177],[100.6030426,19.3327751],[100.5269318,19.3491631],[100.5183334,19.4038849],[100.5719299,19.4677734],[100.4841958,19.488411],[100.482948,19.547559],[100.478023,19.612634],[100.427875,19.672665],[100.406563,19.735507],[100.3417825,19.6880451],[100.316965,19.6486768],[100.2174835,19.6355515],[100.1830902,19.6125603],[100.1663818,19.5483322],[100.0763931,19.4261055],[100.0038834,19.4286079],[99.9594269,19.389164],[99.9130402,19.3952751],[99.9224854,19.4580498],[99.852478,19.467495],[99.7277679,19.4505501],[99.6871948,19.4297943]]]},"properties":{"shapeName":"Phayao Province","shapeISO":"TH-56","shapeID":"36821470B12194119812739","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.0889864,15.8874716],[99.0974904,15.8329879],[99.1330135,15.7827652],[99.2171453,15.7663683],[99.2473091,15.7223574],[99.3701297,15.6631483],[99.3842835,15.6207579],[99.4283679,15.6105206],[99.478898,15.5656175],[99.5219987,15.5637711],[99.5855408,15.6202745],[99.6952515,15.6788864],[99.7505493,15.6702766],[99.8199921,15.6230526],[99.8605957,15.6345062],[99.8702545,15.5649986],[99.9158173,15.5119438],[99.9563751,15.5147209],[100.0455322,15.4511089],[100.0579376,15.4196358],[100.1244202,15.4183311],[100.2163773,15.350275],[100.2922134,15.2233315],[100.3091431,15.1674976],[100.3491516,15.1549988],[100.3760834,15.0838871],[100.442749,15.060276],[100.4777679,15.1041641],[100.4888763,15.1636105],[100.5299835,15.1966648],[100.5630264,15.2705536],[100.7433167,15.434721],[100.7394333,15.4974976],[100.7960968,15.5547199],[100.8308334,15.6222191],[100.8311005,15.6799984],[100.7891464,15.8052769],[100.7991486,15.8544426],[100.7549896,15.9305534],[100.602478,15.9524965],[100.5061035,15.9247208],[100.4144287,15.9230537],[100.3152618,15.9449978],[100.2933197,15.914444],[100.2208328,15.9230537],[100.1677628,15.9555531],[100.1274719,16.0324974],[100.0855408,16.1511078],[100.0269318,16.217495],[100.0341492,16.2580528],[99.9944305,16.3377762],[99.9516602,16.2961082],[99.9469299,16.2266617],[99.8997192,16.1288834],[99.8958282,16.0805511],[99.8280487,16.0224991],[99.7613831,15.9205532],[99.730545,15.8263874],[99.6560974,15.7894421],[99.6138763,15.8811092],[99.5374241,15.9249422],[99.4772546,15.9023445],[99.3576669,15.9268887],[99.2812557,15.898067],[99.195595,15.9395691],[99.0889864,15.8874716]]]},"properties":{"shapeName":"Nakhon Sawan Province","shapeISO":"TH-60","shapeID":"36821470B58837026258254","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.0808334,17.6433296],[102.1361084,17.5519409],[102.1249771,17.4802742],[102.0788727,17.4519386],[101.9885864,17.4513855],[101.9844208,17.4174957],[102.0260925,17.327774],[101.9844208,17.2811089],[101.9449768,17.2061043],[101.977478,17.1463852],[102.0885925,17.1052742],[102.152771,17.0177765],[102.0330505,16.9486046],[102.0708038,16.8851582],[102.190931,16.8592582],[102.2777654,16.8939439],[102.3247672,16.8668368],[102.3436877,16.8178067],[102.4097137,16.7922211],[102.5216522,16.7902756],[102.5544281,16.7624969],[102.6098175,16.7691231],[102.68839,16.9869806],[102.574645,17.0376032],[102.5202669,17.1002104],[102.5119315,17.1855499],[102.5409067,17.2606161],[102.4964516,17.2841155],[102.4440581,17.2795674],[102.4460427,17.3390619],[102.3968245,17.4352731],[102.3678493,17.5227283],[102.3436371,17.5514922],[102.3265695,17.6808733],[102.2876712,17.6547775],[102.1932041,17.6593162],[102.0808334,17.6433296]]]},"properties":{"shapeName":"Nong Bua Lam Phu Province","shapeISO":"TH-39","shapeID":"36821470B48254980905782","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.8489758,13.4791495],[100.9735379,13.4643368],[101.0180511,13.474165],[101.0599823,13.5497208],[101.1572113,13.5886097],[101.2972107,13.538887],[101.3660889,13.4552755],[101.4038696,13.4588871],[101.4408112,13.4127769],[101.4894333,13.4172211],[101.5338669,13.3908329],[101.5619202,13.3322201],[101.6358185,13.2938881],[101.6827469,13.2480526],[101.7174835,13.1715679],[101.7702637,13.1808319],[101.9952469,13.3361988],[101.9455338,13.4819412],[101.9983215,13.5],[101.9877625,13.5872192],[101.9163818,13.5855541],[101.922493,13.6313858],[101.8888931,13.6591644],[101.8691406,13.7255545],[101.8138733,13.7205524],[101.7491531,13.8038864],[101.6977463,13.7863855],[101.6788635,13.7522221],[101.5944366,13.7847214],[101.5447083,13.7755547],[101.3972168,13.8469429],[101.2213669,13.8777752],[101.1649933,13.851387],[101.1401825,13.8779011],[101.1583328,13.9286098],[101.1401978,13.9684181],[101.0439997,13.9766281],[100.914197,13.9612445],[100.9141408,13.9462063],[100.9022158,13.8488479],[100.9099158,13.7905482],[100.8607551,13.697029],[100.9557667,13.6623807],[100.9594134,13.6323244],[100.9040752,13.5541119],[100.8794476,13.5480772],[100.8489758,13.4791495]]]},"properties":{"shapeName":"Chachoengsao Province","shapeISO":"TH-24","shapeID":"36821470B91271468807469","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.4047897,15.4368992],[103.3429084,15.4085956],[103.2695231,15.4233342],[103.2039514,15.4601478],[103.1113522,15.4813157],[103.0899022,15.4194803],[103.146128,15.4018657],[103.2218307,15.3243783],[103.293416,15.3003069],[103.3282091,15.3373086],[103.3711925,15.3402186],[103.4029374,15.3089194],[103.4437346,15.3441975],[103.5009198,15.2947171],[103.4442304,15.263399],[103.4242658,15.130431],[103.448265,15.0456831],[103.4207796,15.0241918],[103.4259812,14.9286886],[103.3793052,14.8687247],[103.3124691,14.8141894],[103.2508163,14.617775],[103.2297134,14.5922203],[103.2369232,14.5016651],[103.2569427,14.4541655],[103.242675,14.337482],[103.2663661,14.3497046],[103.4676658,14.3647012],[103.4981701,14.4121251],[103.5575721,14.4319217],[103.621449,14.4040545],[103.6595076,14.4398213],[103.7002694,14.3918762],[103.8817087,14.3380214],[103.9499373,14.3504122],[104.0461597,14.3410979],[104.0630264,14.4463863],[104.0416565,14.5149994],[104.0674896,14.6155529],[104.0583191,14.6636105],[104.0066528,14.7286091],[103.9922028,14.8105555],[103.9163818,14.8586082],[103.9527664,14.9355555],[104.0202637,15.0291653],[103.9952469,15.1411095],[103.9694366,15.1669426],[103.9888763,15.2197189],[103.9758072,15.2563877],[104.0433197,15.33111],[104.0633087,15.3863869],[103.9828186,15.4033546],[103.9347076,15.4408321],[103.8630371,15.4563866],[103.7752686,15.4349976],[103.6399765,15.4488869],[103.6077576,15.4652748],[103.5391464,15.4386101],[103.4047897,15.4368992]]]},"properties":{"shapeName":"Surin Province","shapeISO":"TH-32","shapeID":"36821470B33660676013176","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.4097595,15],[101.4505463,14.9208317],[101.4441376,14.8591633],[101.3458328,14.8047199],[101.3377686,14.7752762],[101.2744293,14.7286091],[101.1993482,14.7557989],[101.2149602,14.5915573],[101.1849823,14.5436096],[101.2427979,14.4797459],[101.2774811,14.5061092],[101.4092331,14.4248333],[101.4830475,14.4616642],[101.5413666,14.4258308],[101.6322021,14.4266644],[101.7510986,14.3133316],[101.7636108,14.348608],[101.8697128,14.3652744],[101.9863663,14.3524971],[101.947197,14.288332],[101.9602737,14.2561092],[102.0791626,14.1602764],[102.1197128,14.1438885],[102.1658173,14.1372204],[102.1955338,14.1880531],[102.23526,14.1383324],[102.3027573,14.1277752],[102.364151,14.1580544],[102.4274673,14.1572208],[102.5388794,14.1208305],[102.5941467,14.1486092],[102.5830536,14.203886],[102.6172028,14.26861],[102.5594177,14.4547195],[102.4983215,14.5008316],[102.4844208,14.5722198],[102.5097198,14.6552773],[102.4816589,14.7536106],[102.4435883,14.8074989],[102.578598,14.8224983],[102.5466537,14.8949986],[102.560257,14.9372196],[102.7072067,14.9524965],[102.7608185,14.9922199],[102.7772064,15.0719433],[102.8591537,15.1577759],[102.9180527,15.1822205],[102.9808197,15.1866646],[102.973877,15.3347206],[102.951828,15.3803463],[102.9777527,15.4237289],[102.942749,15.5044441],[102.8274765,15.5113888],[102.798027,15.5469437],[102.7890472,15.6393738],[102.723877,15.666666],[102.6916504,15.7199974],[102.6138763,15.7197189],[102.5577469,15.7024965],[102.5155487,15.7180538],[102.4255371,15.6927757],[102.3347321,15.8055267],[102.2672119,15.7813873],[102.2563782,15.7558327],[102.1635971,15.7249985],[102.1136017,15.6333332],[102.0816727,15.619997],[102.0345306,15.650919],[102.0041733,15.5927753],[101.9505463,15.5347214],[101.9658203,15.4813862],[101.8999863,15.4305534],[101.8852692,15.3847218],[101.8130264,15.3866653],[101.7188721,15.3372192],[101.6274719,15.3027763],[101.5655365,15.2533321],[101.4063721,15.2133312],[101.3449936,15.2863855],[101.3001938,15.3019714],[101.2785873,15.244442],[101.2746964,15.0916653],[101.3785934,15.0638866],[101.4097595,15]]]},"properties":{"shapeName":"Nakhon Ratchasima Province","shapeISO":"TH-30","shapeID":"36821470B25997690360286","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.3160147,17.2688673],[99.3130341,17.1405525],[99.2780457,17.060276],[99.2672119,16.9588852],[99.2808228,16.9269409],[99.3458252,16.8877754],[99.3749847,16.8649979],[99.4947052,16.888607],[99.5424805,16.8608284],[99.5552521,16.8147202],[99.6744232,16.7397156],[99.8583221,16.734993],[99.9044342,16.6880493],[99.8852692,16.738327],[99.9316559,16.7863846],[99.9488678,16.8624954],[100.0213776,16.8547173],[100.0324936,16.8911057],[100.1233063,16.890831],[100.1277618,16.9694405],[100.0080414,17.0436096],[99.9288635,17.1191635],[99.9197083,17.1647186],[99.9827576,17.1655502],[99.9685974,17.2483292],[100.0105362,17.3002739],[100.0163879,17.3783302],[100.0374908,17.4361076],[100.0197067,17.4738846],[99.9277649,17.5713844],[99.9727631,17.6202736],[99.9694366,17.7049942],[99.9249878,17.7455482],[99.8963928,17.8036575],[99.7849884,17.7863846],[99.7433167,17.7249947],[99.6624908,17.7972183],[99.6002655,17.8172188],[99.5502625,17.8074989],[99.4733124,17.7288818],[99.4780426,17.6747169],[99.4502716,17.5961075],[99.4183197,17.5644417],[99.4396973,17.5269432],[99.4094238,17.4680519],[99.4719238,17.365551],[99.4772034,17.3202744],[99.4435883,17.2280502],[99.3941498,17.2019386],[99.3394449,17.266934],[99.3160147,17.2688673]]]},"properties":{"shapeName":"Sukhothai Province","shapeISO":"TH-64","shapeID":"36821470B85895478064474","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.204172,13.20408],[99.13178,13.203082],[99.135777,13.128427],[99.099511,13.072735],[99.170854,13.015062],[99.184926,12.861725],[99.234457,12.780365],[99.247623,12.71939],[99.333725,12.665465],[99.378923,12.615953],[99.440483,12.58146],[99.5219269,12.5858307],[99.618042,12.5424976],[99.6788635,12.5791655],[99.7516632,12.5780544],[99.8207603,12.6299224],[99.9557670911411,12.634092971735749],[99.9762108,12.7725054],[100.0241141,12.8954289],[100.0406834,12.965209],[100.1024733,13.0556263],[100.0657295,13.0845528],[100.0689181,13.1504973],[100.0065468,13.1980584],[99.951959,13.2898399],[99.8552551,13.269165],[99.8585968,13.328331],[99.7605438,13.3330536],[99.7033234,13.3119431],[99.668045,13.2727757],[99.6677551,13.2224979],[99.6358185,13.1988869],[99.5263824,13.1991653],[99.4958191,13.1786098],[99.4177551,13.1922207],[99.3472137,13.2297192],[99.3205414,13.1658335],[99.2569427,13.1736088],[99.204172,13.20408]]]},"properties":{"shapeName":"Phetchaburi Province","shapeISO":"TH-76","shapeID":"36821470B90667358259218","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.3347321,15.8055267],[102.3313828,15.8536091],[102.360527,15.8977776],[102.3905487,15.9936085],[102.2636108,16.0655518],[102.3277664,16.1269417],[102.4105377,16.2505531],[102.46138,16.3966637],[102.3989839,16.4465561],[102.3749668,16.434785],[102.2440663,16.4694477],[102.1824224,16.5062923],[102.0823907,16.4924334],[101.9894845,16.5667911],[101.9561005,16.6333313],[101.8266221,16.65775],[101.7764887,16.6326719],[101.7503907,16.6680038],[101.6333313,16.6827736],[101.5986023,16.7049942],[101.5574799,16.6713829],[101.5066528,16.6688843],[101.4960938,16.6097183],[101.5102692,16.5433311],[101.4688131,16.5197763],[101.4745252,16.4772966],[101.4277603,16.432005],[101.3744202,16.348053],[101.3449936,16.2602768],[101.3685913,16.2374954],[101.3252716,16.0877762],[101.3405533,15.9811096],[101.3863831,15.9563866],[101.3724823,15.8966656],[101.3252716,15.8461094],[101.3194427,15.7741661],[101.3347168,15.7391644],[101.3961029,15.6869431],[101.3636017,15.5611095],[101.3719177,15.4063873],[101.3371964,15.3383312],[101.3001938,15.3019714],[101.3449936,15.2863855],[101.4063721,15.2133312],[101.5655365,15.2533321],[101.6274719,15.3027763],[101.7188721,15.3372192],[101.8130264,15.3866653],[101.8852692,15.3847218],[101.8999863,15.4305534],[101.9658203,15.4813862],[101.9505463,15.5347214],[102.0041733,15.5927753],[102.0345306,15.650919],[102.0816727,15.619997],[102.1136017,15.6333332],[102.1635971,15.7249985],[102.2563782,15.7558327],[102.2672119,15.7813873],[102.3347321,15.8055267]]]},"properties":{"shapeName":"Chaiyaphum Province","shapeISO":"TH-36","shapeID":"36821470B61102131347890","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.7394333,15.4974976],[100.8799667,15.4652748],[100.9736023,15.4830532],[101.04776,15.4266644],[101.1224823,15.3958321],[101.2397003,15.3974991],[101.2555466,15.3361092],[101.3001938,15.3019714],[101.3371964,15.3383312],[101.3719177,15.4063873],[101.3636017,15.5611095],[101.3961029,15.6869431],[101.3347168,15.7391644],[101.3194427,15.7741661],[101.3252716,15.8461094],[101.3724823,15.8966656],[101.3863831,15.9563866],[101.3405533,15.9811096],[101.3252716,16.0877762],[101.3685913,16.2374954],[101.3449936,16.2602768],[101.3744202,16.348053],[101.4277603,16.432005],[101.4745252,16.4772966],[101.4688131,16.5197763],[101.5102692,16.5433311],[101.4960938,16.6097183],[101.5066528,16.6688843],[101.5574799,16.6713829],[101.5986023,16.7049942],[101.6333313,16.6827736],[101.7503907,16.6680038],[101.7964178,16.7333717],[101.7878626,16.7641205],[101.7247994,16.7549282],[101.6867354,16.7989566],[101.7147064,16.8286095],[101.7194366,16.9016609],[101.6830368,16.9577713],[101.64888,17.0463867],[101.602478,17.0041656],[101.5419312,17],[101.5319006,17.0549711],[101.4036054,17.0909048],[101.2722168,17.0813866],[101.231369,17.1116638],[101.1699829,17.0449982],[101.1255264,17.0311089],[101.1208191,16.926384],[101.0669201,16.8998228],[101.0294266,16.9005508],[100.9366531,16.7972183],[100.8933334,16.7908325],[100.9133072,16.7111053],[100.8369293,16.5955505],[100.8313828,16.5205536],[100.7730408,16.4502716],[100.6680527,16.3470688],[100.6994171,16.2497177],[100.6463776,16.1724968],[100.6288605,16.0477753],[100.6933136,16.0352745],[100.7730408,15.9783306],[100.7549896,15.9305534],[100.7991486,15.8544426],[100.7891464,15.8052769],[100.8311005,15.6799984],[100.8308334,15.6222191],[100.7960968,15.5547199],[100.7394333,15.4974976]]]},"properties":{"shapeName":"Phetchabun Province","shapeISO":"TH-67","shapeID":"36821470B55463016005684","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[99.97282,9.78829],[99.9844571,9.7098941],[100.06881,9.6792973],[100.0728126,9.7664046],[100.016442,9.8021207],[99.97282,9.78829]]],[[[98.5108032,9.3063469],[98.5330505,9.2372189],[98.4902649,9.1411095],[98.4810944,9.0636101],[98.5166626,8.9858303],[98.4569397,8.9547195],[98.4580383,8.8841648],[98.504715,8.8755531],[98.5727692,8.7972202],[98.6088715,8.7830544],[98.6038666,8.7147198],[98.5472107,8.6524982],[98.591095,8.6230526],[98.6719208,8.6363869],[98.7133179,8.6249981],[98.75,8.6694431],[98.7999878,8.6438885],[98.8519287,8.6422215],[98.9294281,8.5608311],[98.883606,8.5149994],[98.9083252,8.4716644],[98.8930511,8.4324989],[98.8986053,8.3494415],[99.0063782,8.3152752],[99.0666656,8.3549976],[99.2072144,8.3216648],[99.2658234,8.3413887],[99.2397003,8.3899994],[99.2880402,8.4844418],[99.324707,8.5194435],[99.4152679,8.5691643],[99.445816,8.6280537],[99.4927521,8.6719437],[99.528595,8.7561092],[99.5272064,8.8461094],[99.5605316,8.8577747],[99.5830536,8.9102764],[99.6238708,8.9052773],[99.6938629,8.9297199],[99.7441406,9.0208321],[99.7766571,9.0477772],[99.7717239,9.1107542],[99.7288666,9.1547203],[99.7555542,9.2186089],[99.7491455,9.2788868],[99.7684119,9.3223721],[99.648482,9.3052046],[99.6101695,9.2677168],[99.518985,9.275366],[99.4661959,9.1983517],[99.3777056,9.1884168],[99.2922531,9.2373752],[99.2238494,9.2568658],[99.219264,9.3386816],[99.2624272,9.3608392],[99.3007351,9.4107888],[99.2438321,9.4809909],[99.1734563,9.6508826],[99.1401898,9.7951628],[99.0508118,9.7599983],[99.0302734,9.7299976],[98.8777618,9.7238865],[98.777771,9.6808319],[98.7497101,9.6011086],[98.75,9.5272217],[98.653595,9.4186096],[98.6644287,9.3477764],[98.551651,9.3538857],[98.5108032,9.3063469]]],[[[99.9346109963158,9.5011268],[99.9586335,9.4190564],[100.0128788,9.4207312],[100.0665215,9.4861819],[100.050844,9.5572869],[99.9830145,9.5874394],[99.9154576,9.570995],[99.9346109963158,9.5011268]]]]},"properties":{"shapeName":"Surat Thani Province","shapeISO":"TH-84","shapeID":"36821470B94320894939793","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.9828186,15.4033546],[104.0633087,15.3863869],[104.0433197,15.33111],[103.9758072,15.2563877],[103.9888763,15.2197189],[103.9694366,15.1669426],[103.9952469,15.1411095],[104.0202637,15.0291653],[103.9527664,14.9355555],[103.9163818,14.8586082],[103.9922028,14.8105555],[104.0066528,14.7286091],[104.0583191,14.6636105],[104.0674896,14.6155529],[104.0416565,14.5149994],[104.0630264,14.4463863],[104.0461597,14.3410979],[104.1080676,14.3784352],[104.1716471,14.3588742],[104.24754,14.3775996],[104.2753551,14.4085322],[104.321657,14.3739167],[104.3782646,14.3778977],[104.4759893,14.358745],[104.4989892,14.3762344],[104.5656788,14.3545027],[104.6409487,14.423193],[104.6987045,14.4273492],[104.7548315,14.4049413],[104.8058488,14.4374188],[104.8336097,14.4012271],[104.9072817,14.399397],[104.8608093,14.521944],[104.8311005,14.5522213],[104.8499908,14.6327744],[104.8869324,14.6613884],[104.8158112,14.8122196],[104.8330536,14.8483315],[104.8135986,14.891943],[104.7338715,14.9233322],[104.6977463,14.9572201],[104.6879425,15.1373291],[104.7374878,15.1683311],[104.6744232,15.2508316],[104.5841522,15.2408314],[104.4677582,15.2591648],[104.4180908,15.2846975],[104.3527679,15.3366642],[104.3085938,15.3374977],[104.2474823,15.3708305],[104.2047119,15.4469433],[104.1552582,15.4952745],[104.1293182,15.564497],[104.0794373,15.5347214],[103.9828186,15.4033546]]]},"properties":{"shapeName":"Si Sa Ket Province","shapeISO":"TH-33","shapeID":"36821470B45378358106724","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.0583496,18.0881996],[102.1108093,18.0624962],[102.1530533,17.9919395],[102.1797028,18.0136108],[102.2413635,18.0013885],[102.3272095,17.9497185],[102.4447021,17.8147202],[102.4655533,17.7563858],[102.5352631,17.7552757],[102.610527,17.6899948],[102.6208191,17.6205521],[102.6780396,17.5938873],[102.7769318,17.6191635],[102.7835999,17.6563835],[102.8380432,17.6861076],[102.8633118,17.7263832],[102.8483276,17.806942],[102.8863831,17.8627739],[102.9366531,17.8874969],[102.9885864,17.8202744],[103.0299835,17.868885],[103.0991516,17.8599968],[103.1719208,17.7997208],[103.1919327,17.8411064],[103.2638931,17.8813858],[103.3155365,17.829998],[103.3994787,17.866521],[103.3883209,17.9249954],[103.3997192,17.9861069],[103.2587106,18.2621801],[103.298211,18.304024],[103.182301,18.265776],[103.151152,18.229488],[103.136057,18.160459],[103.086339,18.131642],[103.078551,18.03374],[103.018771,17.971293],[102.968872,18.003927],[102.85506,17.970849],[102.779224,17.926711],[102.740279,17.883526],[102.679187,17.856731],[102.66676,17.80545],[102.598171,17.834424],[102.61386,17.938141],[102.554504,17.969103],[102.460039,17.971035],[102.346463,18.044625],[102.286273,18.060587],[102.185541,18.147898],[102.163325,18.204438],[102.105219,18.213733],[102.0583496,18.0881996]]]},"properties":{"shapeName":"Nong Khai Province","shapeISO":"TH-43","shapeID":"36821470B10823236588148","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.9141408,13.9462063],[100.6868292,13.9162736],[100.5675998,13.951353],[100.543703,13.8497479],[100.5010877,13.8010047],[100.3278772,13.8041844],[100.3329945,13.7210924],[100.3484118,13.6407102],[100.3755366,13.6188015],[100.4090046,13.4934205],[100.4533529,13.5041207],[100.4538767,13.6060269],[100.5210167,13.6051441],[100.5175771,13.6672274],[100.5794589,13.669285],[100.6358068,13.6487187],[100.6982318,13.6558631],[100.7099173,13.7169711],[100.7879663,13.7167233],[100.8607551,13.697029],[100.9099158,13.7905482],[100.9022158,13.8488479],[100.9141408,13.9462063]]]},"properties":{"shapeName":"Bangkok","shapeISO":"TH-10","shapeID":"36821470B28205534186964","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.0708038,16.8851582],[101.9469086,16.8497146],[101.8708191,16.7958298],[101.7878626,16.7641205],[101.7964178,16.7333717],[101.7503907,16.6680038],[101.7764887,16.6326719],[101.8266221,16.65775],[101.9561005,16.6333313],[101.9894845,16.5667911],[102.0823907,16.4924334],[102.1824224,16.5062923],[102.2440663,16.4694477],[102.3749668,16.434785],[102.3989839,16.4465561],[102.46138,16.3966637],[102.4105377,16.2505531],[102.3277664,16.1269417],[102.2636108,16.0655518],[102.3905487,15.9936085],[102.360527,15.8977776],[102.3313828,15.8536091],[102.3347321,15.8055267],[102.4255371,15.6927757],[102.5155487,15.7180538],[102.5577469,15.7024965],[102.6138763,15.7197189],[102.6916504,15.7199974],[102.723877,15.666666],[102.7890472,15.6393738],[102.8338776,15.6224976],[102.8894272,15.6702766],[102.8916626,15.7527771],[102.946373,15.7891645],[102.9405365,15.8580532],[102.9052582,15.9286098],[102.9144287,15.9905529],[102.8574829,16.079998],[102.8699799,16.1094398],[102.8483276,16.2608299],[102.9563828,16.3972168],[102.9483185,16.4486046],[103.0116577,16.4644394],[103.0888824,16.5105553],[103.1608124,16.6086082],[103.1625137,16.643425],[103.1644287,16.7583313],[103.1947021,16.8308296],[103.1129837,16.912632],[102.989151,16.848053],[102.946373,16.8405533],[102.8694305,16.8805504],[102.832489,16.8755531],[102.8299866,16.9808273],[102.7583313,17.0319405],[102.6924667,17.0383301],[102.68839,16.9869806],[102.6098175,16.7691231],[102.5544281,16.7624969],[102.5216522,16.7902756],[102.4097137,16.7922211],[102.3436877,16.8178067],[102.3247672,16.8668368],[102.2777654,16.8939439],[102.190931,16.8592582],[102.0708038,16.8851582]]]},"properties":{"shapeName":"Khon Kaen Province","shapeISO":"TH-40","shapeID":"36821470B82174860486434","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.0808334,17.6433296],[102.0335999,17.679718],[102.0227737,17.7402725],[102.0513763,17.8255539],[102.0219269,17.9819374],[102.0583496,18.0881996],[102.105219,18.213733],[102.04496,18.198412],[102.034685,18.161359],[101.937886,18.077401],[101.906916,18.030899],[101.824022,18.060414],[101.768049,18.054248],[101.710583,17.902149],[101.61675,17.893556],[101.569442,17.864171],[101.548093,17.7875665],[101.4535188,17.7381957],[101.3976993,17.6905379],[101.3064766,17.6506957],[101.2592568,17.597574],[101.2295495,17.5363862],[101.1885361,17.5310735],[101.1497029,17.4667943],[101.1158374,17.4787202],[101.0439186,17.5454918],[100.9675502,17.5715152],[100.9269847,17.5699068],[100.8816528,17.466938],[100.9102631,17.4119415],[100.9058228,17.3269424],[100.9808197,17.3294411],[101.0952606,17.2158279],[101.0641479,17.1527748],[101.0474746,17.016837],[101.0303765,17.0074942],[101.0669201,16.8998228],[101.1208191,16.926384],[101.1255264,17.0311089],[101.1699829,17.0449982],[101.231369,17.1116638],[101.2722168,17.0813866],[101.4036054,17.0909048],[101.5319006,17.0549711],[101.5419312,17],[101.602478,17.0041656],[101.64888,17.0463867],[101.6830368,16.9577713],[101.7194366,16.9016609],[101.7147064,16.8286095],[101.6867354,16.7989566],[101.7247994,16.7549282],[101.7878626,16.7641205],[101.8708191,16.7958298],[101.9469086,16.8497146],[102.0708038,16.8851582],[102.0330505,16.9486046],[102.152771,17.0177765],[102.0885925,17.1052742],[101.977478,17.1463852],[101.9449768,17.2061043],[101.9844208,17.2811089],[102.0260925,17.327774],[101.9844208,17.4174957],[101.9885864,17.4513855],[102.0788727,17.4519386],[102.1249771,17.4802742],[102.1361084,17.5519409],[102.0808334,17.6433296]]]},"properties":{"shapeName":"Loei Province","shapeISO":"TH-42","shapeID":"36821470B29302790580634","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.4097595,15],[101.0924835,14.8516636],[101.0308228,14.8419418],[100.9508209,14.7474976],[100.9199829,14.7441645],[100.872757,14.7861099],[100.8209618,14.803186],[100.7407681,14.7805353],[100.7041632,14.744723],[100.6124878,14.6986084],[100.5938721,14.6558323],[100.5713806,14.6033306],[100.5983276,14.5536098],[100.7686005,14.5652752],[100.7616577,14.4102764],[100.7916565,14.3844433],[100.7835999,14.3205528],[100.8208313,14.3149986],[100.8231256,14.2455593],[100.8915552,14.2760491],[100.9516826,14.2718751],[101.0413666,14.3252754],[101.0808334,14.3844433],[101.1608124,14.3469429],[101.2086029,14.3749981],[101.2141571,14.4297199],[101.2427979,14.4797459],[101.1849823,14.5436096],[101.2149602,14.5915573],[101.1993482,14.7557989],[101.2744293,14.7286091],[101.3377686,14.7752762],[101.3458328,14.8047199],[101.4441376,14.8591633],[101.4505463,14.9208317],[101.4097595,15]]]},"properties":{"shapeName":"Saraburi Province","shapeISO":"TH-19","shapeID":"36821470B39713231642809","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.1330135,15.7827652],[99.137326,15.7275589],[99.1161018,15.6280432],[99.0913826,15.5814638],[99.0489323,15.5537157],[99.0779082,15.4897906],[99.0564893,15.4418962],[99.0072402,15.2053814],[98.9853023,15.1808448],[99.0192028,15.0551588],[99.075838,15.0351562],[99.1313758,15.0460817],[99.2575201,14.9839368],[99.2864695,14.9923797],[99.322386,15.0679792],[99.4030457,15.038332],[99.4360962,14.9886093],[99.5102692,14.9838867],[99.6318207,14.9379311],[99.6483307,14.9941645],[99.7258148,14.9611092],[99.7371979,15.0161095],[99.8085938,15.0758305],[99.7538757,15.1630545],[99.716095,15.2738876],[99.7647095,15.3174973],[99.8624878,15.3016644],[99.8877716,15.2841644],[100,15.2936096],[100.0460968,15.2783318],[100.0702667,15.3433304],[100.0349884,15.3816643],[100.0579376,15.4196358],[100.0455322,15.4511089],[99.9563751,15.5147209],[99.9158173,15.5119438],[99.8702545,15.5649986],[99.8605957,15.6345062],[99.8199921,15.6230526],[99.7505493,15.6702766],[99.6952515,15.6788864],[99.5855408,15.6202745],[99.5219987,15.5637711],[99.478898,15.5656175],[99.4283679,15.6105206],[99.3842835,15.6207579],[99.3701297,15.6631483],[99.2473091,15.7223574],[99.2171453,15.7663683],[99.1330135,15.7827652]]]},"properties":{"shapeName":"Uthai Thani Province","shapeISO":"TH-61","shapeID":"36821470B292137368164","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.1401898,9.7951628],[99.1658811,9.8593015],[99.1509845,9.9930416],[99.1722818,10.0411464],[99.1491085,10.1279485],[99.1901784,10.2094454],[99.2208174,10.2270989],[99.1490555,10.3120716],[99.1851377,10.3707485],[99.2389192,10.3438121],[99.28598,10.3678463],[99.2839492,10.4116455],[99.2287524,10.472291],[99.2366562,10.5238974],[99.2777149,10.5512218],[99.2856946,10.6109146],[99.3192416,10.6692118],[99.3591002,10.6823686],[99.4011519,10.7463672],[99.4338619,10.856191],[99.4961189,10.9259038],[99.49912732994268,10.989379040065081],[99.4788666,10.9980526],[99.4041595,10.9536095],[99.3191335,10.9669646],[99.2711029,10.9561081],[99.1472168,11.0094433],[99.143418,11.02999],[99.104527,11.013948],[99.079319,10.954951],[99.023852,10.970734],[98.999286,10.923554],[99.01149,10.856696],[98.958806,10.811986],[98.922846,10.825714],[98.896364,10.778618],[98.9255371,10.7488861],[98.903595,10.6949978],[98.9424744,10.6669426],[98.9783173,10.6016636],[98.9694366,10.5369434],[98.9122009,10.4844418],[98.902771,10.4238873],[98.9541626,10.34972],[98.8999923,10.2904044],[98.8863896,10.1719057],[98.8460115,10.0607368],[98.9022842,10.0528122],[98.8696369,9.9934217],[98.8132584,9.9362677],[98.7814443,9.950264],[98.720573,9.8932042],[98.6288216,9.7571948],[98.6624908,9.6552773],[98.7497101,9.6011086],[98.777771,9.6808319],[98.8777618,9.7238865],[99.0302734,9.7299976],[99.0508118,9.7599983],[99.1401898,9.7951628]]]},"properties":{"shapeName":"Chumphon Province","shapeISO":"TH-86","shapeID":"36821470B15538290209832","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.2440491,16.4328918],[104.2405396,16.5213852],[104.1860962,16.6047173],[104.1866531,16.6647186],[104.0728531,16.8324013],[104.0213776,16.8566628],[103.9597168,16.8177757],[103.9430389,16.7761078],[103.7944336,16.8913841],[103.7783203,16.966938],[103.6500052,17.0435536],[103.5766602,17.1011086],[103.5061035,17.0449982],[103.5094299,16.9955482],[103.4844208,16.9591599],[103.4824829,16.9030495],[103.4494171,16.8622169],[103.3983307,16.9047165],[103.2813721,16.94944],[103.1436005,16.9583282],[103.1129837,16.912632],[103.1947021,16.8308296],[103.1644287,16.7583313],[103.1625137,16.643425],[103.1955338,16.5497208],[103.1752625,16.4947166],[103.2061005,16.3605537],[103.2763824,16.3777733],[103.3577576,16.348053],[103.3708191,16.3080521],[103.4197083,16.2813873],[103.4591522,16.1891632],[103.5111389,16.1982918],[103.5566406,16.2183266],[103.5963669,16.2005501],[103.723999,16.2191658],[103.755374,16.2707246],[103.7019272,16.3816643],[103.8035889,16.4572182],[103.8722076,16.468605],[103.9213715,16.4436073],[104.0415802,16.4399223],[104.0469437,16.3966637],[104.17276,16.446106],[104.2440491,16.4328918]]]},"properties":{"shapeName":"Kalasin","shapeISO":"TH-46","shapeID":"36821470B98454587472110","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.9044342,16.6880493],[99.890274,16.638607],[99.918045,16.5833321],[100.0058289,16.5558319],[100.0319824,16.4960136],[100.1149902,16.5130539],[100.2150801,16.5973676],[100.2491531,16.5330544],[100.3113709,16.5408325],[100.3433228,16.5211105],[100.4197083,16.5380516],[100.5177612,16.538887],[100.5591431,16.5230522],[100.5813828,16.4277725],[100.6680527,16.3470688],[100.7730408,16.4502716],[100.8313828,16.5205536],[100.8369293,16.5955505],[100.9133072,16.7111053],[100.8933334,16.7908325],[100.9366531,16.7972183],[101.0294266,16.9005508],[101.0669201,16.8998228],[101.0303765,17.0074942],[101.0474746,17.016837],[101.0641479,17.1527748],[101.0952606,17.2158279],[100.9808197,17.3294411],[100.9058228,17.3269424],[100.9102631,17.4119415],[100.8816528,17.466938],[100.9269847,17.5699068],[100.9675502,17.5715152],[100.993769,17.631351],[101.006955,17.731877],[100.986649,17.748144],[100.9310913,17.6905518],[100.8616486,17.697773],[100.8302765,17.6399956],[100.7972107,17.6272202],[100.7356073,17.5402794],[100.6907108,17.5240535],[100.548027,17.4072189],[100.4483699,17.4246924],[100.3783112,17.3858299],[100.2530518,17.3444405],[100.2513886,17.2311058],[100.1841431,17.1872177],[100.0052567,17.1799965],[99.9827576,17.1655502],[99.9197083,17.1647186],[99.9288635,17.1191635],[100.0080414,17.0436096],[100.1277618,16.9694405],[100.1233063,16.890831],[100.0324936,16.8911057],[100.0213776,16.8547173],[99.9488678,16.8624954],[99.9316559,16.7863846],[99.8852692,16.738327],[99.9044342,16.6880493]]]},"properties":{"shapeName":"Phitsanulok Province","shapeISO":"TH-65","shapeID":"36821470B89877881758540","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.7497101,9.6011086],[98.6624908,9.6552773],[98.6288216,9.7571948],[98.720573,9.8932042],[98.7814443,9.950264],[98.8132584,9.9362677],[98.8696369,9.9934217],[98.9022842,10.0528122],[98.8460115,10.0607368],[98.8863896,10.1719057],[98.8999923,10.2904044],[98.9541626,10.34972],[98.902771,10.4238873],[98.9122009,10.4844418],[98.9694366,10.5369434],[98.9783173,10.6016636],[98.9424744,10.6669426],[98.903595,10.6949978],[98.9255371,10.7488861],[98.896364,10.778618],[98.863022,10.778816],[98.7798841,10.6787081],[98.7744623,10.6162356],[98.8078733,10.5887273],[98.808825,10.4689622],[98.7554698,10.3932457],[98.7001966,10.2131705],[98.6523202,10.1610359],[98.6453419,10.1136879],[98.5696626,9.9584616],[98.5906187,9.9368479],[98.5214196,9.8434777],[98.5396098,9.8041492],[98.5126571,9.7594789],[98.5578175,9.7291519],[98.4690431,9.5932476],[98.4394477,9.5135775],[98.4300118,9.4408707],[98.3978417,9.4033462],[98.3917146,9.3459028],[98.4258118,9.3577747],[98.5108032,9.3063469],[98.551651,9.3538857],[98.6644287,9.3477764],[98.653595,9.4186096],[98.75,9.5272217],[98.7497101,9.6011086]]]},"properties":{"shapeName":"Ranong Province","shapeISO":"TH-85","shapeID":"36821470B8565245416798","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.68839,16.9869806],[102.6924667,17.0383301],[102.7583313,17.0319405],[102.8299866,16.9808273],[102.832489,16.8755531],[102.8694305,16.8805504],[102.946373,16.8405533],[102.989151,16.848053],[103.1129837,16.912632],[103.1436005,16.9583282],[103.2813721,16.94944],[103.3983307,16.9047165],[103.4494171,16.8622169],[103.4824829,16.9030495],[103.4844208,16.9591599],[103.5094299,16.9955482],[103.5061035,17.0449982],[103.5766602,17.1011086],[103.6500052,17.0435536],[103.6302756,17.1089108],[103.5844458,17.1658527],[103.4566687,17.2227947],[103.4022253,17.2166874],[103.3425024,17.2483532],[103.2738989,17.2627994],[103.2463873,17.3155758],[103.2924994,17.3841869],[103.2700079,17.4305736],[103.2808417,17.4802982],[103.3280676,17.5675213],[103.3897284,17.59641],[103.4050024,17.7291843],[103.3774908,17.7528011],[103.3780554,17.8333561],[103.3994787,17.866521],[103.3155365,17.829998],[103.2638931,17.8813858],[103.1919327,17.8411064],[103.1719208,17.7997208],[103.0991516,17.8599968],[103.0299835,17.868885],[102.9885864,17.8202744],[102.9366531,17.8874969],[102.8863831,17.8627739],[102.8483276,17.806942],[102.8633118,17.7263832],[102.8380432,17.6861076],[102.7835999,17.6563835],[102.7769318,17.6191635],[102.6780396,17.5938873],[102.6208191,17.6205521],[102.610527,17.6899948],[102.5352631,17.7552757],[102.4655533,17.7563858],[102.4447021,17.8147202],[102.3272095,17.9497185],[102.2413635,18.0013885],[102.1797028,18.0136108],[102.1530533,17.9919395],[102.1108093,18.0624962],[102.0583496,18.0881996],[102.0219269,17.9819374],[102.0513763,17.8255539],[102.0227737,17.7402725],[102.0335999,17.679718],[102.0808334,17.6433296],[102.1932041,17.6593162],[102.2876712,17.6547775],[102.3265695,17.6808733],[102.3436371,17.5514922],[102.3678493,17.5227283],[102.3968245,17.4352731],[102.4460427,17.3390619],[102.4440581,17.2795674],[102.4964516,17.2841155],[102.5409067,17.2606161],[102.5119315,17.1855499],[102.5202669,17.1002104],[102.574645,17.0376032],[102.68839,16.9869806]]]},"properties":{"shapeName":"Udon Thani Province","shapeISO":"TH-41","shapeID":"36821470B31576844110237","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.3994787,17.866521],[103.3780554,17.8333561],[103.3774908,17.7528011],[103.4050024,17.7291843],[103.3897284,17.59641],[103.3280676,17.5675213],[103.2808417,17.4802982],[103.2700079,17.4305736],[103.2924994,17.3841869],[103.2463873,17.3155758],[103.2738989,17.2627994],[103.3425024,17.2483532],[103.4022253,17.2166874],[103.4566687,17.2227947],[103.5844458,17.1658527],[103.6302756,17.1089108],[103.6500052,17.0435536],[103.7783203,16.966938],[103.7944336,16.8913841],[103.9430389,16.7761078],[103.9597168,16.8177757],[104.0213776,16.8566628],[104.0728531,16.8324013],[104.106369,16.8366623],[104.1336136,16.8936081],[104.1933136,16.8824959],[104.2463684,16.8988838],[104.3105316,16.9411049],[104.324707,16.9794388],[104.3919373,17.014164],[104.3605804,17.0914841],[104.3999863,17.1222191],[104.4013824,17.1744385],[104.4433136,17.23666],[104.4191437,17.4149971],[104.3099823,17.4222183],[104.2252731,17.404995],[104.1574936,17.4111061],[104.1344299,17.3813858],[104.047493,17.3919411],[104.0086136,17.4763832],[104.0494232,17.5791626],[104.0302734,17.635273],[104.0797119,17.6738853],[104.0102997,17.8010101],[103.9502716,17.7749977],[103.8947067,17.8449974],[103.8408203,17.867218],[103.7599869,17.8719406],[103.7724915,17.904995],[103.6738663,17.9394379],[103.6205292,17.9711075],[103.5880432,18.0430527],[103.5099869,18.0647202],[103.4763794,18.0113869],[103.3997192,17.9861069],[103.3883209,17.9249954],[103.3994787,17.866521]]]},"properties":{"shapeName":"Sakon Nakhon Province","shapeISO":"TH-47","shapeID":"36821470B62746704581928","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.406563,19.735507],[100.442293,19.835559],[100.500769,19.870125],[100.551632,20.030846],[100.578496,20.167794],[100.507395,20.151016],[100.452904,20.204035],[100.330536,20.398228],[100.272329,20.400626],[100.213428,20.312847],[100.167476,20.298123],[100.171684,20.245772],[100.112457,20.250004],[100.0596144,20.396187],[99.9999216,20.4160017],[99.9603321,20.4648337],[99.8619448,20.4432666],[99.826765,20.3571393],[99.776264,20.334784],[99.730143,20.350117],[99.681463,20.316183],[99.519651,20.361267],[99.500358,20.336879],[99.521406,20.250275],[99.563785,20.205936],[99.513272,20.147538],[99.5635986,20.1108284],[99.5030518,20.060276],[99.5916595,20.0080528],[99.5883179,19.9655495],[99.4530487,19.9761047],[99.3833313,19.8841629],[99.4138794,19.8402748],[99.3691406,19.7744408],[99.3633118,19.7258263],[99.2627716,19.6486053],[99.2664775,19.6112698],[99.3212106,19.5350784],[99.2935944,19.4941597],[99.3341522,19.3663864],[99.3170942,19.3516775],[99.3438721,19.264164],[99.3031151,19.1937526],[99.3234914,19.120069],[99.3811679,19.0500973],[99.4012801,18.9999594],[99.5350264,19.0832752],[99.5595933,19.133357],[99.5352805,19.1633075],[99.5822144,19.2955513],[99.5705414,19.3949966],[99.587204,19.468605],[99.6447144,19.5036087],[99.6871948,19.4297943],[99.7277679,19.4505501],[99.852478,19.467495],[99.9224854,19.4580498],[99.9130402,19.3952751],[99.9594269,19.389164],[100.0038834,19.4286079],[100.0763931,19.4261055],[100.1663818,19.5483322],[100.1830902,19.6125603],[100.2174835,19.6355515],[100.316965,19.6486768],[100.3417825,19.6880451],[100.406563,19.735507]]]},"properties":{"shapeName":"Chiang Rai Province","shapeISO":"TH-57","shapeID":"36821470B41943429581493","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.0889864,15.8874716],[99.0761362,15.9608304],[99.1092635,16.0998016],[99.0687663,16.1324186],[99.0804906,16.2183317],[99.048983,16.2574493],[99.0513131,16.3342389],[99.0219374,16.4271831],[98.9850178,16.4728966],[99.114151,16.4752731],[99.1760864,16.5258293],[99.2544403,16.5630531],[99.2433167,16.6269417],[99.2988739,16.7238846],[99.2983246,16.7874985],[99.3302765,16.8216629],[99.3458252,16.8877754],[99.2808228,16.9269409],[99.2672119,16.9588852],[99.2780457,17.060276],[99.3130341,17.1405525],[99.3160147,17.2688673],[99.2705383,17.3444405],[99.2724915,17.385273],[99.223877,17.3994408],[99.1726397,17.335756],[99.0388794,17.3841629],[99.0055542,17.4208298],[99.0224915,17.4699936],[98.9375154,17.4690939],[98.9120224,17.5322268],[98.8708567,17.4914069],[98.8286545,17.511804],[98.7543192,17.4679521],[98.7058941,17.4867052],[98.6852089,17.6468835],[98.7471873,17.6879546],[98.7044373,17.7519417],[98.6638794,17.7372169],[98.6110992,17.6658287],[98.6271973,17.6055527],[98.6069336,17.5691643],[98.5508118,17.5505524],[98.5344238,17.4611053],[98.5669403,17.406105],[98.5630341,17.2877769],[98.4172058,17.2483292],[98.3369293,17.3008308],[98.3424835,17.3327751],[98.3108063,17.3977737],[98.3241577,17.5261078],[98.306366,17.5686073],[98.2577667,17.5858307],[98.2619324,17.656662],[98.302475,17.6958275],[98.2377625,17.6819382],[98.2022095,17.6463852],[98.1158142,17.6449966],[97.9910889,17.5908318],[97.9116428,17.5667808],[97.9232181,17.539461],[97.9926483,17.5098745],[98.0573469,17.4382326],[98.1126394,17.3194298],[98.1983969,17.2543741],[98.2799963,17.1675116],[98.2800583,17.1158159],[98.3377133,17.0511852],[98.3800184,17.0594826],[98.438172,17.0360131],[98.5322692,16.9003764],[98.4921423,16.8420082],[98.5398409,16.8266011],[98.4631732,16.7302208],[98.5119215,16.7007245],[98.5115162,16.6490811],[98.5738727,16.6207482],[98.5785044,16.5674687],[98.6342396,16.4777732],[98.67064,16.285163],[98.735731,16.338479],[98.775634,16.349114],[98.828339,16.41321],[98.881438,16.423026],[98.913306,16.392794],[98.931746,16.328888],[98.902832,16.253732],[98.855821,16.225291],[98.848512,16.127866],[98.699128,16.135718],[98.661417,16.073327],[98.561462,16.031951],[98.608651,15.972609],[98.584024,15.918138],[98.610764,15.876798],[98.576913,15.800242],[98.548287,15.685239],[98.558125,15.655036],[98.6445948,15.6375152],[98.8738708,15.3144436],[98.8919373,15.2724991],[98.9611364,15.2288141],[98.9853023,15.1808448],[99.0072402,15.2053814],[99.0564893,15.4418962],[99.0779082,15.4897906],[99.0489323,15.5537157],[99.0913826,15.5814638],[99.1161018,15.6280432],[99.137326,15.7275589],[99.1330135,15.7827652],[99.0974904,15.8329879],[99.0889864,15.8874716]]]},"properties":{"shapeName":"Tak Province","shapeISO":"TH-63","shapeID":"36821470B1341269906223","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.3278772,13.8041844],[100.2761333,13.9129271],[100.2982559,13.9634093],[100.2624326,13.998109],[100.2796192,14.1264577],[100.2861023,14.1613884],[100.2210388,14.1772976],[100.1608124,14.1399994],[99.9802704,14.1449986],[99.872757,14.0616646],[99.8113708,14.0616741],[99.8280487,13.996109],[99.8799591,13.9444075],[99.931366,13.9327745],[99.96138,13.8319416],[99.9474792,13.7452745],[100.0044577,13.7416153],[100.0704508,13.6516902],[100.1135485,13.6789624],[100.1816444,13.6890427],[100.2441587,13.674474],[100.2890176,13.7129235],[100.3329945,13.7210924],[100.3278772,13.8041844]]]},"properties":{"shapeName":"Nakhon Pathom Province","shapeISO":"TH-73","shapeID":"36821470B57969901614599","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.9120224,17.5322268],[98.8795145,17.6114139],[98.9050265,17.6435065],[98.9844208,17.6363831],[99.0499878,17.6144409],[99.0794373,17.7252731],[99.1652679,17.8108292],[99.1783142,17.8605537],[99.0913696,17.9533272],[99.0947113,17.9916611],[99.0627594,18.0558319],[99.0577545,18.1355515],[99.0141602,18.25],[99.0980377,18.2686081],[99.1436005,18.3136101],[99.1627655,18.3666649],[99.2602692,18.4947166],[99.3146973,18.5297203],[99.2147064,18.5961075],[99.2208252,18.6519394],[99.176651,18.7072182],[99.0052643,18.6633301],[98.9289506,18.5408045],[98.7648443,18.4346978],[98.7095189,18.4138036],[98.6676372,18.306058],[98.786377,18.2491608],[98.8216553,18.0824966],[98.7988739,18.0211105],[98.8085938,17.9802742],[98.8513794,17.9705505],[98.911377,17.8197174],[98.8983307,17.7452736],[98.8391571,17.7147179],[98.7583313,17.7574997],[98.7044373,17.7519417],[98.7471873,17.6879546],[98.6852089,17.6468835],[98.7058941,17.4867052],[98.7543192,17.4679521],[98.8286545,17.511804],[98.8708567,17.4914069],[98.9120224,17.5322268]]]},"properties":{"shapeName":"Lamphun Province","shapeISO":"TH-51","shapeID":"36821470B42817227172980","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[102.3756063,11.9986352],[102.4467716,11.9764479],[102.3630648,12.0951182],[102.2974806,12.1441586],[102.2667532,12.1327466],[102.2880392,12.0123876],[102.3109651,11.9708173],[102.3756063,11.9986352]]],[[[102.5315954,11.6819653],[102.5664569,11.5939522],[102.608785,11.6450907],[102.5540482,11.7623252],[102.5315954,11.6819653]]],[[[102.2575661,12.2969948],[102.3139291,12.1820983],[102.3444848,12.1993807],[102.3981534,12.1679378],[102.4644095,12.1602181],[102.5595765,12.0968443],[102.5602632,12.1827694],[102.5915055,12.2102865],[102.6402574,12.1805881],[102.6627659,12.1212841],[102.7552914,12.0410473],[102.770291,12.074485],[102.70351,12.172681],[102.7175185,12.2223609],[102.725802,12.364311],[102.784524,12.399872],[102.76532,12.456903],[102.677549,12.543153],[102.645269,12.611101],[102.566812,12.657974],[102.511082,12.66987],[102.5063581,12.7512655],[102.4444275,12.7302761],[102.3916626,12.6672211],[102.3907166,12.5998993],[102.3516464,12.5855541],[102.3255463,12.5247211],[102.3483276,12.4686089],[102.3254652,12.4275878],[102.3684347,12.4017188],[102.3437444,12.3348153],[102.3048537,12.3008032],[102.2575661,12.2969948]]]]},"properties":{"shapeName":"Trat Province","shapeISO":"TH-23","shapeID":"36821470B56570402043943","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.8585968,13.328331],[99.9124908,13.5063877],[99.9744263,13.4908314],[100.0377731,13.5044441],[100.0435867,13.581665],[100.0704508,13.6516902],[100.0044577,13.7416153],[99.9474792,13.7452745],[99.96138,13.8319416],[99.931366,13.9327745],[99.8799591,13.9444075],[99.8058167,13.8152752],[99.6891479,13.8224983],[99.5922089,13.7958317],[99.551651,13.7572212],[99.5141602,13.7608318],[99.4710999,13.8005543],[99.4433136,13.7774982],[99.3505402,13.75],[99.2580414,13.7688885],[99.2010956,13.7277756],[99.166965,13.726142],[99.187251,13.496821],[99.206935,13.380191],[99.192105,13.32276],[99.204172,13.20408],[99.2569427,13.1736088],[99.3205414,13.1658335],[99.3472137,13.2297192],[99.4177551,13.1922207],[99.4958191,13.1786098],[99.5263824,13.1991653],[99.6358185,13.1988869],[99.6677551,13.2224979],[99.668045,13.2727757],[99.7033234,13.3119431],[99.7605438,13.3330536],[99.8585968,13.328331]]]},"properties":{"shapeName":"Ratchaburi Province","shapeISO":"TH-70","shapeID":"36821470B93544771177010","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.1293182,15.564497],[104.1552582,15.4952745],[104.2047119,15.4469433],[104.2474823,15.3708305],[104.3085938,15.3374977],[104.3527679,15.3366642],[104.4180908,15.2846975],[104.4430389,15.3447208],[104.3841629,15.3980541],[104.4030533,15.5291653],[104.4916534,15.5311108],[104.519989,15.5588865],[104.4630432,15.6008301],[104.4246979,15.7249985],[104.4641571,15.9105549],[104.5030518,15.9572201],[104.5633087,16.0794411],[104.6158066,16.1208305],[104.805542,16.156105],[104.8128433,16.1904564],[104.7119293,16.2108269],[104.6683197,16.2374954],[104.6369324,16.2113838],[104.5938721,16.243885],[104.5591431,16.3099976],[104.5086136,16.3374977],[104.3952637,16.3447189],[104.3559265,16.3233299],[104.3236084,16.2552757],[104.2719269,16.23666],[104.2327576,16.1672173],[104.1788635,16.1424942],[104.0977631,16.0788841],[104.1191406,16.0374985],[104.1085968,15.9819412],[104.0264435,15.9810123],[104.0149753,15.8864233],[104.0350202,15.796098],[104.0774356,15.8386023],[104.133954,15.7996039],[104.127903,15.7207166],[104.1615477,15.687995],[104.1847076,15.5966644],[104.1293182,15.564497]]]},"properties":{"shapeName":"Yasothon Province","shapeISO":"TH-35","shapeID":"36821470B86493246441872","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.1125053,18.8083348],[100.0977859,18.7314163],[100.1123543,18.6698293],[100.0997436,18.6299424],[100.1188736,18.5713844],[100.0619202,18.5527763],[100.0524673,18.4897156],[100.0049896,18.404995],[99.9647064,18.3797188],[99.9144287,18.2930527],[99.8594208,18.2686081],[99.7611084,18.1458282],[99.7216492,18.0663872],[99.5883179,18.0008316],[99.5099945,17.9391632],[99.3663788,17.7730522],[99.3149872,17.7319374],[99.3810883,17.6766624],[99.4733124,17.7288818],[99.5502625,17.8074989],[99.6002655,17.8172188],[99.6624908,17.7972183],[99.7433167,17.7249947],[99.7849884,17.7863846],[99.8963928,17.8036575],[99.9502716,17.7816658],[100.0038834,17.8336067],[100.060257,17.8355522],[100.141098,17.8619423],[100.2277679,17.9308281],[100.2935867,17.9552727],[100.3435974,17.8933296],[100.3699799,17.9277725],[100.3744202,17.9908295],[100.4125303,18.0194218],[100.3980106,18.134907],[100.4516602,18.1513863],[100.4922028,18.310276],[100.5422134,18.4166641],[100.5419312,18.4547157],[100.4894333,18.4933281],[100.4130402,18.4880486],[100.3852692,18.5677757],[100.4074936,18.6299973],[100.3553279,18.6771984],[100.3624001,18.7488643],[100.3401674,18.8066022],[100.3011715,18.8187216],[100.1125053,18.8083348]]]},"properties":{"shapeName":"Phrae Province","shapeISO":"TH-54","shapeID":"36821470B82770800477257","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.841687,6.301846],[100.903742,6.233598],[100.947036,6.263988],[101.031631,6.244259],[101.094945,6.260441],[101.126671,6.192645],[101.058677,6.142269],[101.119067,6.114503],[101.1015,5.951448],[101.085024,5.90915],[101.037068,5.918044],[101.014208,5.882415],[100.989456,5.78393],[101.0374553,5.7365328],[101.0829491,5.7204303],[101.136803,5.612993],[101.214977,5.661413],[101.25999,5.711545],[101.249612,5.762401],[101.276339,5.811805],[101.374071,5.83661],[101.396906,5.872412],[101.487721,5.87335],[101.502268,5.889718],[101.4827423,5.9830542],[101.4788513,6.0669441],[101.4335785,6.1338882],[101.4002533,6.1558323],[101.4138565,6.3163872],[101.439682,6.4216652],[101.5433197,6.4444427],[101.6169281,6.5386105],[101.6016235,6.5553818],[101.5558167,6.5430546],[101.5180511,6.5955544],[101.3302612,6.5497208],[101.2730331,6.6402769],[101.2341309,6.6022215],[101.1449738,6.604166],[101.1010666,6.6377773],[101.1091309,6.5966663],[101.053299,6.5333328],[101.0302582,6.476944],[100.9871826,6.4749994],[100.9235764,6.4066658],[100.9249725,6.3199997],[100.8891373,6.2938881],[100.841687,6.301846]]]},"properties":{"shapeName":"Yala Province","shapeISO":"TH-95","shapeID":"36821470B19626602766038","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.9827576,17.1655502],[100.0052567,17.1799965],[100.1841431,17.1872177],[100.2513886,17.2311058],[100.2530518,17.3444405],[100.3783112,17.3858299],[100.4483699,17.4246924],[100.548027,17.4072189],[100.6907108,17.5240535],[100.7356073,17.5402794],[100.7972107,17.6272202],[100.8302765,17.6399956],[100.8616486,17.697773],[100.9310913,17.6905518],[100.986649,17.748144],[101.028435,17.828798],[101.018692,17.890963],[101.116703,17.951058],[101.12162,18.000127],[101.183956,18.0651],[101.160936,18.123643],[101.19266,18.213042],[101.144204,18.258599],[101.182166,18.340537],[101.085738,18.380277],[100.919152,18.2420581],[100.9143145,18.1594744],[100.8463433,18.0814783],[100.8189826,18.0835232],[100.7727403,18.0268278],[100.6927605,18.024194],[100.6100832,18.0496061],[100.5441548,18.0155163],[100.4125303,18.0194218],[100.3744202,17.9908295],[100.3699799,17.9277725],[100.3435974,17.8933296],[100.2935867,17.9552727],[100.2277679,17.9308281],[100.141098,17.8619423],[100.060257,17.8355522],[100.0038834,17.8336067],[99.9502716,17.7816658],[99.8963928,17.8036575],[99.9249878,17.7455482],[99.9694366,17.7049942],[99.9727631,17.6202736],[99.9277649,17.5713844],[100.0197067,17.4738846],[100.0374908,17.4361076],[100.0163879,17.3783302],[100.0105362,17.3002739],[99.9685974,17.2483292],[99.9827576,17.1655502]]]},"properties":{"shapeName":"Uttaradit Province","shapeISO":"TH-53","shapeID":"36821470B55067065784454","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.1113522,15.4813157],[103.0873002,15.5384108],[103.0451293,15.5735624],[102.9726304,15.6678933],[103.0079434,15.7011948],[102.946373,15.7891645],[102.8916626,15.7527771],[102.8894272,15.6702766],[102.8338776,15.6224976],[102.7890472,15.6393738],[102.798027,15.5469437],[102.8274765,15.5113888],[102.942749,15.5044441],[102.9777527,15.4237289],[102.951828,15.3803463],[102.973877,15.3347206],[102.9808197,15.1866646],[102.9180527,15.1822205],[102.8591537,15.1577759],[102.7772064,15.0719433],[102.7608185,14.9922199],[102.7072067,14.9524965],[102.560257,14.9372196],[102.5466537,14.8949986],[102.578598,14.8224983],[102.4435883,14.8074989],[102.4816589,14.7536106],[102.5097198,14.6552773],[102.4844208,14.5722198],[102.4983215,14.5008316],[102.5594177,14.4547195],[102.6172028,14.26861],[102.5830536,14.203886],[102.5941467,14.1486092],[102.6116486,14.1338882],[102.7224884,14.1316643],[102.8244324,14.1674976],[102.8777618,14.1524982],[102.9357589,14.1680932],[102.9624511,14.2122165],[103.0220781,14.2285059],[103.0791235,14.2938773],[103.1746234,14.3356524],[103.242675,14.337482],[103.2569427,14.4541655],[103.2369232,14.5016651],[103.2297134,14.5922203],[103.2508163,14.617775],[103.3124691,14.8141894],[103.3793052,14.8687247],[103.4259812,14.9286886],[103.4207796,15.0241918],[103.448265,15.0456831],[103.4242658,15.130431],[103.4442304,15.263399],[103.5009198,15.2947171],[103.4437346,15.3441975],[103.4029374,15.3089194],[103.3711925,15.3402186],[103.3282091,15.3373086],[103.293416,15.3003069],[103.2218307,15.3243783],[103.146128,15.4018657],[103.0899022,15.4194803],[103.1113522,15.4813157]]]},"properties":{"shapeName":"Buri Ram Province","shapeISO":"TH-31","shapeID":"36821470B4901960309299","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.9944305,16.3377762],[99.9766541,16.3697166],[100.0319824,16.4960136],[100.0058289,16.5558319],[99.918045,16.5833321],[99.890274,16.638607],[99.9044342,16.6880493],[99.8583221,16.734993],[99.6744232,16.7397156],[99.5552521,16.8147202],[99.5424805,16.8608284],[99.4947052,16.888607],[99.3749847,16.8649979],[99.3458252,16.8877754],[99.3302765,16.8216629],[99.2983246,16.7874985],[99.2988739,16.7238846],[99.2433167,16.6269417],[99.2544403,16.5630531],[99.1760864,16.5258293],[99.114151,16.4752731],[98.9850178,16.4728966],[99.0219374,16.4271831],[99.0513131,16.3342389],[99.048983,16.2574493],[99.0804906,16.2183317],[99.0687663,16.1324186],[99.1092635,16.0998016],[99.0761362,15.9608304],[99.0889864,15.8874716],[99.195595,15.9395691],[99.2812557,15.898067],[99.3576669,15.9268887],[99.4772546,15.9023445],[99.5374241,15.9249422],[99.6138763,15.8811092],[99.6560974,15.7894421],[99.730545,15.8263874],[99.7613831,15.9205532],[99.8280487,16.0224991],[99.8958282,16.0805511],[99.8997192,16.1288834],[99.9469299,16.2266617],[99.9516602,16.2961082],[99.9944305,16.3377762]]]},"properties":{"shapeName":"Kamphaeng Phet Province","shapeISO":"TH-62","shapeID":"36821470B22142474761729","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.6871948,19.4297943],[99.6447144,19.5036087],[99.587204,19.468605],[99.5705414,19.3949966],[99.5822144,19.2955513],[99.5352805,19.1633075],[99.5595933,19.133357],[99.5350264,19.0832752],[99.4012801,18.9999594],[99.3738708,18.9852715],[99.3862331,18.8697931],[99.3497918,18.8363958],[99.3666534,18.7619438],[99.3302765,18.6863823],[99.3522034,18.6158295],[99.3146973,18.5297203],[99.2602692,18.4947166],[99.1627655,18.3666649],[99.1436005,18.3136101],[99.0980377,18.2686081],[99.0141602,18.25],[99.0577545,18.1355515],[99.0627594,18.0558319],[99.0947113,17.9916611],[99.0913696,17.9533272],[99.1783142,17.8605537],[99.1652679,17.8108292],[99.0794373,17.7252731],[99.0499878,17.6144409],[98.9844208,17.6363831],[98.9050265,17.6435065],[98.8795145,17.6114139],[98.9120224,17.5322268],[98.9375154,17.4690939],[99.0224915,17.4699936],[99.0055542,17.4208298],[99.0388794,17.3841629],[99.1726397,17.335756],[99.223877,17.3994408],[99.2724915,17.385273],[99.2705383,17.3444405],[99.3160147,17.2688673],[99.3394449,17.266934],[99.3941498,17.2019386],[99.4435883,17.2280502],[99.4772034,17.3202744],[99.4719238,17.365551],[99.4094238,17.4680519],[99.4396973,17.5269432],[99.4183197,17.5644417],[99.4502716,17.5961075],[99.4780426,17.6747169],[99.4733124,17.7288818],[99.3810883,17.6766624],[99.3149872,17.7319374],[99.3663788,17.7730522],[99.5099945,17.9391632],[99.5883179,18.0008316],[99.7216492,18.0663872],[99.7611084,18.1458282],[99.8594208,18.2686081],[99.9144287,18.2930527],[99.9647064,18.3797188],[100.0049896,18.404995],[100.0524673,18.4897156],[100.0619202,18.5527763],[100.1188736,18.5713844],[100.0997436,18.6299424],[100.1123543,18.6698293],[100.0977859,18.7314163],[100.1125053,18.8083348],[100.0397034,18.8983307],[99.9929,18.9086],[99.9286114,18.969504],[99.8301,19.0088],[99.7830261,19.0570539],[99.7060379,19.2403198],[99.7091522,19.3386078],[99.6871948,19.4297943]]]},"properties":{"shapeName":"Lampang Province","shapeISO":"TH-52","shapeID":"36821470B50506304331769","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.5675998,13.951353],[100.5018573,13.9361467],[100.4622189,13.9716994],[100.3545116,14.0027264],[100.3322972,14.0668638],[100.3446339,14.1166112],[100.2796192,14.1264577],[100.2624326,13.998109],[100.2982559,13.9634093],[100.2761333,13.9129271],[100.3278772,13.8041844],[100.5010877,13.8010047],[100.543703,13.8497479],[100.5675998,13.951353]]]},"properties":{"shapeName":"Nonthaburi Province","shapeISO":"TH-12","shapeID":"36821470B99224408335371","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.3997192,17.9861069],[103.4763794,18.0113869],[103.5099869,18.0647202],[103.5880432,18.0430527],[103.6205292,17.9711075],[103.6738663,17.9394379],[103.7724915,17.904995],[103.7599869,17.8719406],[103.8408203,17.867218],[103.8947067,17.8449974],[103.9502716,17.7749977],[104.0102997,17.8010101],[103.9863663,17.8647194],[104.0319366,17.9347191],[104.1027679,17.8608284],[104.1330414,17.9019394],[104.1244202,18],[104.187899,18.02484],[104.105323,18.115444],[104.06057,18.218574],[103.973041,18.33688],[103.858088,18.283761],[103.834128,18.327829],[103.793929,18.342823],[103.689788,18.347113],[103.610466,18.403193],[103.524159,18.425614],[103.457189,18.424882],[103.405734,18.448611],[103.312827,18.433732],[103.24388,18.363954],[103.298211,18.304024],[103.2587106,18.2621801],[103.3997192,17.9861069]]]},"properties":{"shapeName":"Bueng Kan Province","shapeISO":"TH-38","shapeID":"36821470B57809552794573","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.502268,5.889718],[101.587704,5.930461],[101.623328,5.877125],[101.663426,5.86623],[101.658266,5.812515],[101.714419,5.763435],[101.754056,5.796998],[101.812183,5.740938],[101.8364232,5.78934],[101.8809818,5.8101814],[101.9439863,5.8719906],[101.9308136,5.9456106],[101.9481446,5.9908325],[102.0034497,6.0503364],[102.053897,6.0800378],[102.0929897,6.1460098],[102.0770054,6.2520113],[101.9415493,6.3473292],[101.874066,6.4247576],[101.8148719,6.4533017],[101.72663877605514,6.575122610871342],[101.6800232,6.5739207],[101.6331407,6.636439],[101.5932137,6.6252932],[101.6016235,6.5553818],[101.6169281,6.5386105],[101.5433197,6.4444427],[101.439682,6.4216652],[101.4138565,6.3163872],[101.4002533,6.1558323],[101.4335785,6.1338882],[101.4788513,6.0669441],[101.4827423,5.9830542],[101.502268,5.889718]]]},"properties":{"shapeName":"Narathiwat Province","shapeISO":"TH-96","shapeID":"36821470B28924551841133","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.9072817,14.399397],[104.9724566,14.392335],[105.0013552,14.3660185],[104.9844892,14.3182923],[105.0501935,14.2148958],[105.1091008,14.2141529],[105.1465417,14.2422008],[105.1757555,14.3455349],[105.233587,14.336846],[105.28208,14.35449],[105.320293,14.397454],[105.361122,14.387878],[105.430307,14.42463],[105.507923,14.523483],[105.528945,14.595222],[105.512128,14.75445],[105.522779,14.82502],[105.578558,14.89214],[105.55819,14.924061],[105.594721,14.962107],[105.546954,15.038376],[105.554242,15.06374],[105.47959,15.099541],[105.476133,15.180206],[105.53976,15.252069],[105.590961,15.271222],[105.5746446,15.3289865],[105.5083033,15.3184822],[105.473051,15.335586],[105.494072,15.386402],[105.588905,15.420478],[105.592813,15.509892],[105.624892,15.56347],[105.634708,15.662614],[105.601051,15.720481],[105.495611,15.766287],[105.435484,15.754309],[105.390433,15.808696],[105.342227,15.922246],[105.407885,16.016146],[105.211107,16.049639],[105.0600095,16.0978688],[105.0046945,16.0198437],[105.0230391,15.9770179],[104.964908,15.857882],[104.9577607,15.7910663],[104.9026077,15.7577076],[104.8924824,15.6530103],[104.9276119,15.6073771],[104.8590855,15.5748722],[104.7400552,15.6561517],[104.6356221,15.6875545],[104.5824643,15.6043344],[104.5855143,15.567356],[104.519989,15.5588865],[104.4916534,15.5311108],[104.4030533,15.5291653],[104.3841629,15.3980541],[104.4430389,15.3447208],[104.4180908,15.2846975],[104.4677582,15.2591648],[104.5841522,15.2408314],[104.6744232,15.2508316],[104.7374878,15.1683311],[104.6879425,15.1373291],[104.6977463,14.9572201],[104.7338715,14.9233322],[104.8135986,14.891943],[104.8330536,14.8483315],[104.8158112,14.8122196],[104.8869324,14.6613884],[104.8499908,14.6327744],[104.8311005,14.5522213],[104.8608093,14.521944],[104.9072817,14.399397]]]},"properties":{"shapeName":"Ubon Ratchathani Province","shapeISO":"TH-34","shapeID":"36821470B8778739097958","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.440483,12.58146],[99.421533,12.551948],[99.424986,12.448429],[99.441806,12.400537],[99.433841,12.342799],[99.495241,12.189878],[99.500741,12.143787],[99.564928,12.144886],[99.5341,12.066005],[99.53801,12.014243],[99.592249,11.992515],[99.579413,11.903424],[99.648092,11.802831],[99.636262,11.727168],[99.582802,11.677417],[99.565462,11.626675],[99.517302,11.63647],[99.467844,11.615105],[99.4744,11.533174],[99.447771,11.493301],[99.393728,11.46122],[99.40217,11.388924],[99.312387,11.316381],[99.324582,11.282154],[99.26363,11.214809],[99.228419,11.112074],[99.143418,11.02999],[99.1472168,11.0094433],[99.2711029,10.9561081],[99.3191335,10.9669646],[99.4041595,10.9536095],[99.4788666,10.9980526],[99.49912732994268,10.989379040065081],[99.4872374,11.1141052],[99.5001707,11.1695679],[99.5631227,11.201868],[99.553926,11.3146509],[99.6276301,11.4708348],[99.6386753,11.5179823],[99.7312228,11.6886718],[99.7892915,11.7442701],[99.8119561,11.8802018],[99.8534931,11.9788475],[99.9418573,12.0807742],[100.0160427,12.2001297],[99.974673,12.2645916],[99.9986607,12.3408885],[99.979788,12.4313839],[99.9781898,12.5190943],[99.9557670911411,12.634092971735749],[99.8207603,12.6299224],[99.7516632,12.5780544],[99.6788635,12.5791655],[99.618042,12.5424976],[99.5219269,12.5858307],[99.440483,12.58146]]]},"properties":{"shapeName":"Prachuap Khiri Khan Province","shapeISO":"TH-77","shapeID":"36821470B69255687394343","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.1010666,6.6377773],[101.0594025,6.6599989],[101.036087,6.7474985],[101.05588713006803,6.85429860057464],[101.0067811,6.8552762],[100.9345268,6.889098],[100.8488859,6.9621675],[100.8097312,6.9541788],[100.7331006,7.0211043],[100.6227728,7.1823643],[100.5359073,7.2706039],[100.4667857,7.4120753],[100.4258596,7.5467215],[100.3360758,7.9312311],[100.2724762,7.9236097],[100.1463623,7.8691654],[100.129303,7.8015366],[100.204277,7.7711372],[100.2601166,7.4955893],[100.3009491,7.4889684],[100.3406677,7.525382],[100.3947372,7.493382],[100.4054184,7.3749995],[100.4285825,7.3069312],[100.3766632,7.3084173],[100.333313,7.2416658],[100.2833099,7.204999],[100.2330322,7.2013884],[100.1296768,7.1686096],[100.0663605,7.1336107],[100.0305481,7.0916414],[100.0463562,7.0633326],[100.0810928,6.974721],[100.1219025,6.9294434],[100.1960831,6.9038877],[100.2197037,6.8652763],[100.1924591,6.7858324],[100.205056,6.726082],[100.228761,6.693401],[100.2899695,6.6908214],[100.3194744,6.6539878],[100.3158202,6.6041633],[100.3596421,6.5480568],[100.4081469,6.5287657],[100.4961997,6.5212817],[100.562516,6.495959],[100.652058,6.442584],[100.739071,6.49134],[100.81258,6.442185],[100.841687,6.301846],[100.8891373,6.2938881],[100.9249725,6.3199997],[100.9235764,6.4066658],[100.9871826,6.4749994],[101.0302582,6.476944],[101.053299,6.5333328],[101.1091309,6.5966663],[101.1010666,6.6377773]]]},"properties":{"shapeName":"Songkhla Province","shapeISO":"TH-90","shapeID":"36821470B65611179255944","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.4374,8.09326677214765],[98.3860587,8.0853378],[98.3466453,8.1222957],[98.3393457,8.1683432],[98.284214,8.196519],[98.3016787,8.1092446],[98.2757195,8.0785666],[98.2924167,8.0014334],[98.2716507,7.9255266],[98.3030623,7.778189],[98.3458094,7.8238156],[98.3973305,7.8382359],[98.4223485,7.9039136],[98.3930629,7.9786753],[98.4374,8.09326677214765]]]},"properties":{"shapeName":"Phuket Province","shapeISO":"TH-83","shapeID":"36821470B89498504013794","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.2316589,14.7894421],[100.2124863,14.7663879],[100.215271,14.6761093],[100.1952515,14.5619431],[100.2172089,14.5102768],[100.2538834,14.4863853],[100.4144287,14.5099983],[100.4235992,14.454443],[100.5030518,14.4569416],[100.5005493,14.5480537],[100.5155487,14.6858311],[100.4769287,14.724781],[100.4277573,14.7288857],[100.3569336,14.7666664],[100.2316589,14.7894421]]]},"properties":{"shapeName":"Ang Thong Province","shapeISO":"TH-15","shapeID":"36821470B13392591504269","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.5111389,16.1982918],[103.4591522,16.1891632],[103.4197083,16.2813873],[103.3708191,16.3080521],[103.3577576,16.348053],[103.2763824,16.3777733],[103.2061005,16.3605537],[103.1752625,16.4947166],[103.1955338,16.5497208],[103.1625137,16.643425],[103.1608124,16.6086082],[103.0888824,16.5105553],[103.0116577,16.4644394],[102.9483185,16.4486046],[102.9563828,16.3972168],[102.8483276,16.2608299],[102.8699799,16.1094398],[102.8574829,16.079998],[102.9144287,15.9905529],[102.9052582,15.9286098],[102.9405365,15.8580532],[102.946373,15.7891645],[103.0079434,15.7011948],[102.9726304,15.6678933],[103.0451293,15.5735624],[103.0873002,15.5384108],[103.1113522,15.4813157],[103.2039514,15.4601478],[103.2695231,15.4233342],[103.3429084,15.4085956],[103.4047897,15.4368992],[103.442749,15.5077763],[103.3802567,15.5249996],[103.298317,15.5955524],[103.3544312,15.7019424],[103.4396973,15.7172203],[103.4597168,15.8405533],[103.4972076,15.9266644],[103.4685974,16.1183281],[103.5111389,16.1982918]]]},"properties":{"shapeName":"Maha Sarakham Province","shapeISO":"TH-44","shapeID":"36821470B32359336681268","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.0305481,7.0916414],[99.9666443,7.1697206],[99.9382935,7.251111],[99.9441223,7.2961102],[99.8844147,7.3813877],[99.8852539,7.4147215],[99.8291626,7.5469437],[99.7958221,7.5952768],[99.8197174,7.6530542],[99.7877655,7.7572222],[99.7442169,7.8507481],[99.7294312,7.9024992],[99.7480316,7.9777765],[99.7269287,7.9933319],[99.6058197,7.9797211],[99.5108185,7.870554],[99.4713745,7.8897219],[99.4069366,7.8872213],[99.3380432,7.9430542],[99.3461304,7.9111099],[99.2916565,7.8466663],[99.3399811,7.751111],[99.315567,7.6997204],[99.2598105,7.6574354],[99.3145775,7.5383349],[99.3491056,7.4024608],[99.3378078,7.3794557],[99.392261,7.3153652],[99.4710594,7.2854515],[99.5584543,7.2085241],[99.5852451,7.1470037],[99.7341475,7.1291644],[99.8113556,7.145833],[99.8566284,7.0724993],[99.8955231,7.0791664],[99.9183044,7.1388884],[99.9988556,7.059721],[100.0463562,7.0633326],[100.0305481,7.0916414]]]},"properties":{"shapeName":"Trang Province","shapeISO":"TH-92","shapeID":"36821470B70022128015779","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.2864695,14.9923797],[99.3125308,14.9673494],[99.3280487,14.829443],[99.3705292,14.7658329],[99.4377594,14.7658329],[99.4333191,14.7211094],[99.4991455,14.6966648],[99.5347137,14.7402744],[99.5435944,14.7969437],[99.5952606,14.7844429],[99.6783142,14.7944431],[99.7844238,14.6772213],[99.8444366,14.6577759],[99.8533173,14.5933304],[99.8863831,14.5497208],[99.8288727,14.5236092],[99.8194427,14.2199974],[99.7627716,14.1652756],[99.7760925,14.0933304],[99.8113708,14.0616741],[99.872757,14.0616646],[99.9802704,14.1449986],[100.1608124,14.1399994],[100.2210388,14.1772976],[100.2552567,14.2269421],[100.2536011,14.2774982],[100.2841492,14.3955536],[100.2538834,14.4863853],[100.2172089,14.5102768],[100.1952515,14.5619431],[100.215271,14.6761093],[100.2124863,14.7663879],[100.2316589,14.7894421],[100.2097168,14.8188877],[100.2299805,14.8630524],[100.2112885,14.9549637],[100.1463776,14.9205532],[100.1166534,14.9466648],[100.0397034,14.9547195],[100.02388,14.91222],[99.8788605,14.9194431],[99.7755432,14.9586086],[99.7258148,14.9611092],[99.6483307,14.9941645],[99.6318207,14.9379311],[99.5102692,14.9838867],[99.4360962,14.9886093],[99.4030457,15.038332],[99.322386,15.0679792],[99.2864695,14.9923797]]]},"properties":{"shapeName":"Suphan Buri Province","shapeISO":"TH-72","shapeID":"36821470B33883534921707","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.3091431,15.1674976],[100.2783203,15.1061077],[100.3047028,14.996109],[100.2613831,14.9502754],[100.2112885,14.9549637],[100.2299805,14.8630524],[100.2097168,14.8188877],[100.2316589,14.7894421],[100.3569336,14.7666664],[100.4277573,14.7288857],[100.4769287,14.724781],[100.4924774,14.7458305],[100.4747009,14.8411083],[100.44664,14.86833],[100.4347076,14.9297199],[100.46138,14.9733315],[100.442749,15.060276],[100.3760834,15.0838871],[100.3491516,15.1549988],[100.3091431,15.1674976]]]},"properties":{"shapeName":"Sing Buri Province","shapeISO":"TH-17","shapeID":"36821470B69138836783186","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.951959,13.2898399],[100.0881408,13.4281963],[100.0377731,13.5044441],[99.9744263,13.4908314],[99.9124908,13.5063877],[99.8585968,13.328331],[99.8552551,13.269165],[99.951959,13.2898399]]]},"properties":{"shapeName":"Samut Songkhram Province","shapeISO":"TH-75","shapeID":"36821470B82153023326894","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.6924667,13.1338882],[101.6377716,13.1530533],[101.5544281,13.0694427],[101.4960938,13.0652752],[101.3897095,13.017498],[101.3230362,13.0716648],[101.2760925,13.0455532],[101.192749,13.078886],[101.1560974,13.017498],[101.1035919,13.0316658],[101.0708313,13.0094433],[101.0930481,12.9622192],[101.0910873,12.8963871],[101.0405426,12.8197193],[101.0016632,12.7941647],[100.9844795,12.6919107],[100.991352,12.6558401],[101.1168953,12.6782811],[101.2868984,12.653942],[101.4126532,12.5940883],[101.4741707,12.6333787],[101.5565255,12.6272164],[101.5936505,12.6535895],[101.6340491,12.6434608],[101.7236693,12.7051432],[101.7845184,12.6927105],[101.8305511,12.8377762],[101.7861023,12.9508305],[101.7474823,12.9502754],[101.7422028,13.0261097],[101.7121964,13.0344429],[101.6924667,13.1338882]]]},"properties":{"shapeName":"Rayong Province","shapeISO":"TH-21","shapeID":"36821470B65269516307076","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.9877625,13.5872192],[102.1197128,14.1438885],[102.0791626,14.1602764],[101.9602737,14.2561092],[101.947197,14.288332],[101.9863663,14.3524971],[101.8697128,14.3652744],[101.7636108,14.348608],[101.7510986,14.3133316],[101.6322021,14.4266644],[101.5413666,14.4258308],[101.4830475,14.4616642],[101.4092331,14.4248333],[101.4780426,14.3691645],[101.5155487,14.3105555],[101.5010986,14.2655544],[101.3924866,14.2619438],[101.3883209,14.2186089],[101.2969437,14.1477776],[101.3269272,14.0680542],[101.2222137,14.0547199],[101.1401978,13.9684181],[101.1583328,13.9286098],[101.1401825,13.8779011],[101.1649933,13.851387],[101.2213669,13.8777752],[101.3972168,13.8469429],[101.5447083,13.7755547],[101.5944366,13.7847214],[101.6788635,13.7522221],[101.6977463,13.7863855],[101.7491531,13.8038864],[101.8138733,13.7205524],[101.8691406,13.7255545],[101.8888931,13.6591644],[101.922493,13.6313858],[101.9163818,13.5855541],[101.9877625,13.5872192]]]},"properties":{"shapeName":"Prachin Buri Province","shapeISO":"TH-25","shapeID":"36821470B81436549800024","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.5938721,14.6558323],[100.5155487,14.6858311],[100.5005493,14.5480537],[100.5030518,14.4569416],[100.4235992,14.454443],[100.4144287,14.5099983],[100.2538834,14.4863853],[100.2841492,14.3955536],[100.2536011,14.2774982],[100.2552567,14.2269421],[100.2210388,14.1772976],[100.2861023,14.1613884],[100.2796192,14.1264577],[100.3446339,14.1166112],[100.4894333,14.1094418],[100.6058854,14.1353503],[100.8231256,14.2455593],[100.8208313,14.3149986],[100.7835999,14.3205528],[100.7916565,14.3844433],[100.7616577,14.4102764],[100.7686005,14.5652752],[100.5983276,14.5536098],[100.5713806,14.6033306],[100.5938721,14.6558323]]]},"properties":{"shapeName":"Phra Nakhon Si Ayutthaya Province","shapeISO":"TH-14","shapeID":"36821470B14521381139968","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.7549896,15.9305534],[100.7730408,15.9783306],[100.6933136,16.0352745],[100.6288605,16.0477753],[100.6463776,16.1724968],[100.6994171,16.2497177],[100.6680527,16.3470688],[100.5813828,16.4277725],[100.5591431,16.5230522],[100.5177612,16.538887],[100.4197083,16.5380516],[100.3433228,16.5211105],[100.3113709,16.5408325],[100.2491531,16.5330544],[100.2150801,16.5973676],[100.1149902,16.5130539],[100.0319824,16.4960136],[99.9766541,16.3697166],[99.9944305,16.3377762],[100.0341492,16.2580528],[100.0269318,16.217495],[100.0855408,16.1511078],[100.1274719,16.0324974],[100.1677628,15.9555531],[100.2208328,15.9230537],[100.2933197,15.914444],[100.3152618,15.9449978],[100.4144287,15.9230537],[100.5061035,15.9247208],[100.602478,15.9524965],[100.7549896,15.9305534]]]},"properties":{"shapeName":"Phichit Province","shapeISO":"TH-66","shapeID":"36821470B91401445526671","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.129303,7.8015366],[100.0507965,7.8169441],[100.008873,7.8788872],[99.9519196,7.9055548],[99.9094086,7.8902769],[99.8121948,7.8866663],[99.7442169,7.8507481],[99.7877655,7.7572222],[99.8197174,7.6530542],[99.7958221,7.5952768],[99.8291626,7.5469437],[99.8852539,7.4147215],[99.8844147,7.3813877],[99.9441223,7.2961102],[99.9382935,7.251111],[99.9666443,7.1697206],[100.0305481,7.0916414],[100.0663605,7.1336107],[100.1296768,7.1686096],[100.2330322,7.2013884],[100.2833099,7.204999],[100.333313,7.2416658],[100.3766632,7.3084173],[100.4285825,7.3069312],[100.4054184,7.3749995],[100.3947372,7.493382],[100.3406677,7.525382],[100.3009491,7.4889684],[100.2601166,7.4955893],[100.204277,7.7711372],[100.129303,7.8015366]]]},"properties":{"shapeName":"Phatthalung Province","shapeISO":"TH-93","shapeID":"36821470B28496430357074","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[98.2708483,8.8712238],[98.3233257,8.9876732],[98.2652904,9.0148794],[98.248653,8.9127227],[98.2708483,8.8712238]]],[[[98.5108032,9.3063469],[98.4258118,9.3577747],[98.3917146,9.3459028],[98.3771469,9.2912553],[98.3211338,9.2131906],[98.3535778,9.0982869],[98.3179532,9.0329409],[98.3614085,9.0008261],[98.3052752,8.9396142],[98.2656805,8.8413463],[98.2613229,8.7618182],[98.2362875,8.7138418],[98.2469407,8.6543858],[98.2051904,8.5530934],[98.2528766,8.3759341],[98.2824319,8.2043998],[98.3599146,8.1886771],[98.3736322,8.1392517],[98.4359121,8.1634985],[98.4682515,8.2198564],[98.4610496,8.2893659],[98.5115149,8.3275355],[98.6344901,8.3746649],[98.6711962,8.4588129],[98.6588745,8.5502758],[98.712204,8.5883312],[98.7133179,8.6249981],[98.6719208,8.6363869],[98.591095,8.6230526],[98.5472107,8.6524982],[98.6038666,8.7147198],[98.6088715,8.7830544],[98.5727692,8.7972202],[98.504715,8.8755531],[98.4580383,8.8841648],[98.4569397,8.9547195],[98.5166626,8.9858303],[98.4810944,9.0636101],[98.4902649,9.1411095],[98.5330505,9.2372189],[98.5108032,9.3063469]]],[[[98.3322966,9.0844042],[98.2913826,9.1654472],[98.2493748,9.1263405],[98.2474577,9.0323084],[98.3027708,9.0377864],[98.3322966,9.0844042]]]]},"properties":{"shapeName":"Phangnga Province","shapeISO":"TH-82","shapeID":"36821470B99917015254292","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.6016235,6.5553818],[101.5932137,6.6252932],[101.6331407,6.636439],[101.6800232,6.5739207],[101.72663877605514,6.575122610871342],[101.5580374,6.8455793],[101.4397262,6.8872233],[101.3540672,6.9056286],[101.3293479,6.8727372],[101.2625916,6.90287],[101.1631819,6.8596101],[101.05588713006803,6.85429860057464],[101.036087,6.7474985],[101.0594025,6.6599989],[101.1010666,6.6377773],[101.1449738,6.604166],[101.2341309,6.6022215],[101.2730331,6.6402769],[101.3302612,6.5497208],[101.5180511,6.5955544],[101.5558167,6.5430546],[101.6016235,6.5553818]]]},"properties":{"shapeName":"Pattani Province","shapeISO":"TH-94","shapeID":"36821470B57629606152034","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.914197,13.9612445],[100.9140203,14.2215155],[100.9516826,14.2718751],[100.8915552,14.2760491],[100.8231256,14.2455593],[100.6058854,14.1353503],[100.4894333,14.1094418],[100.3446339,14.1166112],[100.3322972,14.0668638],[100.3545116,14.0027264],[100.4622189,13.9716994],[100.5018573,13.9361467],[100.5675998,13.951353],[100.6868292,13.9162736],[100.9141408,13.9462063],[100.914197,13.9612445]]]},"properties":{"shapeName":"Pathum Thani Province","shapeISO":"TH-13","shapeID":"36821470B26201996409674","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.2658234,8.3413887],[99.3160858,8.2933311],[99.3177643,8.2010479],[99.2841492,8.1011086],[99.3475037,7.9933319],[99.3380432,7.9430542],[99.4069366,7.8872213],[99.4713745,7.8897219],[99.5108185,7.870554],[99.6058197,7.9797211],[99.7269287,7.9933319],[99.7480316,7.9777765],[99.7294312,7.9024992],[99.7442169,7.8507481],[99.8121948,7.8866663],[99.9094086,7.8902769],[99.9519196,7.9055548],[100.008873,7.8788872],[100.0507965,7.8169441],[100.129303,7.8015366],[100.1463623,7.8691654],[100.2724762,7.9236097],[100.3360758,7.9312311],[100.2934017,8.181408],[100.2561951,8.3266244],[100.2152847,8.441258],[100.1703849,8.5015229],[100.1377527,8.3891701],[100.0790551,8.4290801],[100.0559305,8.4923364],[100.0025391,8.5853864],[99.954029,8.6266499],[99.9167084,8.8940125],[99.9092549,9.099332],[99.8802231,9.1275989],[99.8787687,9.2187761],[99.8087547,9.2992434],[99.7684119,9.3223721],[99.7491455,9.2788868],[99.7555542,9.2186089],[99.7288666,9.1547203],[99.7717239,9.1107542],[99.7766571,9.0477772],[99.7441406,9.0208321],[99.6938629,8.9297199],[99.6238708,8.9052773],[99.5830536,8.9102764],[99.5605316,8.8577747],[99.5272064,8.8461094],[99.528595,8.7561092],[99.4927521,8.6719437],[99.445816,8.6280537],[99.4152679,8.5691643],[99.324707,8.5194435],[99.2880402,8.4844418],[99.2397003,8.3899994],[99.2658234,8.3413887]]]},"properties":{"shapeName":"Nakhon Si Thammarat Province","shapeISO":"TH-80","shapeID":"36821470B62527593836558","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.1401978,13.9684181],[101.2222137,14.0547199],[101.3269272,14.0680542],[101.2969437,14.1477776],[101.3883209,14.2186089],[101.3924866,14.2619438],[101.5010986,14.2655544],[101.5155487,14.3105555],[101.4780426,14.3691645],[101.4092331,14.4248333],[101.2774811,14.5061092],[101.2427979,14.4797459],[101.2141571,14.4297199],[101.2086029,14.3749981],[101.1608124,14.3469429],[101.0808334,14.3844433],[101.0413666,14.3252754],[100.9516826,14.2718751],[100.9140203,14.2215155],[100.914197,13.9612445],[101.0439997,13.9766281],[101.1401978,13.9684181]]]},"properties":{"shapeName":"Nakhon Nayok Province","shapeISO":"TH-26","shapeID":"36821470B61535785460971","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.3559265,16.3233299],[104.3952637,16.3447189],[104.5086136,16.3374977],[104.5591431,16.3099976],[104.5938721,16.243885],[104.6369324,16.2113838],[104.6683197,16.2374954],[104.7119293,16.2108269],[104.8128433,16.1904564],[104.8886032,16.2111053],[104.9776733,16.2792614],[104.903466,16.338277],[104.830181,16.46472],[104.773441,16.495163],[104.733283,16.564363],[104.76243,16.689545],[104.7370029,16.8053846],[104.2463684,16.8988838],[104.1933136,16.8824959],[104.1336136,16.8936081],[104.106369,16.8366623],[104.0728531,16.8324013],[104.1866531,16.6647186],[104.1860962,16.6047173],[104.2405396,16.5213852],[104.2440491,16.4328918],[104.3188782,16.4547157],[104.3277664,16.3294411],[104.3559265,16.3233299]]]},"properties":{"shapeName":"Mukdahan Province","shapeISO":"TH-49","shapeID":"36821470B23119655123949","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.442749,15.060276],[100.46138,14.9733315],[100.4347076,14.9297199],[100.44664,14.86833],[100.4747009,14.8411083],[100.4924774,14.7458305],[100.4769287,14.724781],[100.5155487,14.6858311],[100.5938721,14.6558323],[100.6124878,14.6986084],[100.7041632,14.744723],[100.7407681,14.7805353],[100.8209618,14.803186],[100.872757,14.7861099],[100.9199829,14.7441645],[100.9508209,14.7474976],[101.0308228,14.8419418],[101.0924835,14.8516636],[101.4097595,15],[101.3785934,15.0638866],[101.2746964,15.0916653],[101.2785873,15.244442],[101.3001938,15.3019714],[101.2555466,15.3361092],[101.2397003,15.3974991],[101.1224823,15.3958321],[101.04776,15.4266644],[100.9736023,15.4830532],[100.8799667,15.4652748],[100.7394333,15.4974976],[100.7433167,15.434721],[100.5630264,15.2705536],[100.5299835,15.1966648],[100.4888763,15.1636105],[100.4777679,15.1041641],[100.442749,15.060276]]]},"properties":{"shapeName":"Lopburi Province","shapeISO":"TH-16","shapeID":"36821470B25269958791686","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[99.0897276,7.6801488],[99.0389814,7.6817675],[99.0594763,7.6211609],[99.0933948,7.6128424],[99.1292477,7.6541102],[99.0897276,7.6801488]]],[[[98.7133179,8.6249981],[98.712204,8.5883312],[98.6588745,8.5502758],[98.6711962,8.4588129],[98.6344901,8.3746649],[98.6254876,8.2910256],[98.7021644,8.3119116],[98.7515317,8.1842099],[98.7412446,8.0781363],[98.7654775,8.0362008],[98.8014981,8.0460762],[98.8350114,8.0082172],[98.920211,8.0605634],[98.955,8.04975],[98.94225,7.98775],[98.9725825,7.9328207],[99.0658575,7.8691238],[99.0230285,7.8279793],[99.0200698,7.7638146],[99.040464,7.703007],[99.1077906,7.706915],[99.1190443,7.7686995],[99.1768304,7.7048083],[99.2598105,7.6574354],[99.315567,7.6997204],[99.3399811,7.751111],[99.2916565,7.8466663],[99.3461304,7.9111099],[99.3380432,7.9430542],[99.3475037,7.9933319],[99.2841492,8.1011086],[99.3177643,8.2010479],[99.3160858,8.2933311],[99.2658234,8.3413887],[99.2072144,8.3216648],[99.0666656,8.3549976],[99.0063782,8.3152752],[98.8986053,8.3494415],[98.8930511,8.4324989],[98.9083252,8.4716644],[98.883606,8.5149994],[98.9294281,8.5608311],[98.8519287,8.6422215],[98.7999878,8.6438885],[98.75,8.6694431],[98.7133179,8.6249981]]]]},"properties":{"shapeName":"Krabi Province","shapeISO":"TH-81","shapeID":"36821470B15753947122052","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.9735379,13.4643368],[100.9829006,13.3978961],[100.9701027,13.3523249],[100.9025201,13.2979578],[100.9363624,13.2030534],[100.8717522,13.0834691],[100.9210208,12.9901513],[100.8557686,12.9099651],[100.8982203,12.8564953],[100.9133382,12.8066262],[100.8563807,12.7641386],[100.8339758,12.7134682],[100.8613281,12.6525516],[100.991352,12.6558401],[100.9844795,12.6919107],[101.0016632,12.7941647],[101.0405426,12.8197193],[101.0910873,12.8963871],[101.0930481,12.9622192],[101.0708313,13.0094433],[101.1035919,13.0316658],[101.1560974,13.017498],[101.192749,13.078886],[101.2760925,13.0455532],[101.3230362,13.0716648],[101.3897095,13.017498],[101.4960938,13.0652752],[101.5544281,13.0694427],[101.6377716,13.1530533],[101.6924667,13.1338882],[101.7174835,13.1715679],[101.6827469,13.2480526],[101.6358185,13.2938881],[101.5619202,13.3322201],[101.5338669,13.3908329],[101.4894333,13.4172211],[101.4408112,13.4127769],[101.4038696,13.4588871],[101.3660889,13.4552755],[101.2972107,13.538887],[101.1572113,13.5886097],[101.0599823,13.5497208],[101.0180511,13.474165],[100.9735379,13.4643368]]]},"properties":{"shapeName":"Chon Buri Province","shapeISO":"TH-20","shapeID":"36821470B74825096412992","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.513272,20.147538],[99.438201,20.090609],[99.378395,20.089302],[99.302146,20.113413],[99.159197,20.132822],[99.076954,20.101131],[99.044441,20.038265],[99.031985,19.973848],[99.033996,19.855545],[98.9868957,19.7395475],[98.930255,19.7738373],[98.8984178,19.7658317],[98.849694,19.807979],[98.778854,19.75279],[98.672355,19.751885],[98.6081,19.709137],[98.538084,19.68933],[98.466728,19.692641],[98.4905396,19.6527748],[98.5505371,19.6088867],[98.5763855,19.5138855],[98.6094208,19.4752731],[98.5927582,19.3719406],[98.6058197,19.3566628],[98.6080322,19.234993],[98.5866547,19.2052727],[98.6497192,19.1274948],[98.64888,19.0666656],[98.6160889,19.0424957],[98.5330505,19.0255547],[98.3361053,19.0347214],[98.2474823,19.1419411],[98.2080383,19.1644402],[98.1555481,19.1477737],[98.1158142,19.0808296],[98.133606,19.0427742],[98.1091461,19.0008316],[98.1244202,18.9299965],[98.0808258,18.8236084],[98.1002655,18.7738876],[98.118042,18.6063843],[98.1149902,18.5358315],[98.1672058,18.4361076],[98.1069336,18.3599968],[98.1210938,18.3105545],[98.0913696,18.23666],[98.1038666,18.1488838],[98.1296997,18.0999985],[98.0938721,18.0605545],[98.0861053,17.9966621],[98.1563721,17.9308281],[98.2444305,17.8213844],[98.2108154,17.7708321],[98.2402649,17.7177734],[98.302475,17.6958275],[98.2619324,17.656662],[98.2577667,17.5858307],[98.306366,17.5686073],[98.3241577,17.5261078],[98.3108063,17.3977737],[98.3424835,17.3327751],[98.3369293,17.3008308],[98.4172058,17.2483292],[98.5630341,17.2877769],[98.5669403,17.406105],[98.5344238,17.4611053],[98.5508118,17.5505524],[98.6069336,17.5691643],[98.6271973,17.6055527],[98.6110992,17.6658287],[98.6638794,17.7372169],[98.7044373,17.7519417],[98.7583313,17.7574997],[98.8391571,17.7147179],[98.8983307,17.7452736],[98.911377,17.8197174],[98.8513794,17.9705505],[98.8085938,17.9802742],[98.7988739,18.0211105],[98.8216553,18.0824966],[98.786377,18.2491608],[98.6676372,18.306058],[98.7095189,18.4138036],[98.7648443,18.4346978],[98.9289506,18.5408045],[99.0052643,18.6633301],[99.176651,18.7072182],[99.2208252,18.6519394],[99.2147064,18.5961075],[99.3146973,18.5297203],[99.3522034,18.6158295],[99.3302765,18.6863823],[99.3666534,18.7619438],[99.3497918,18.8363958],[99.3862331,18.8697931],[99.3738708,18.9852715],[99.4012801,18.9999594],[99.3811679,19.0500973],[99.3234914,19.120069],[99.3031151,19.1937526],[99.3438721,19.264164],[99.3170942,19.3516775],[99.3341522,19.3663864],[99.2935944,19.4941597],[99.3212106,19.5350784],[99.2664775,19.6112698],[99.2627716,19.6486053],[99.3633118,19.7258263],[99.3691406,19.7744408],[99.4138794,19.8402748],[99.3833313,19.8841629],[99.4530487,19.9761047],[99.5883179,19.9655495],[99.5916595,20.0080528],[99.5030518,20.060276],[99.5635986,20.1108284],[99.513272,20.147538]]]},"properties":{"shapeName":"Chiang Mai Province","shapeISO":"TH-50","shapeID":"36821470B31171150995182","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.0579376,15.4196358],[100.0349884,15.3816643],[100.0702667,15.3433304],[100.0460968,15.2783318],[100,15.2936096],[99.8877716,15.2841644],[99.8624878,15.3016644],[99.7647095,15.3174973],[99.716095,15.2738876],[99.7538757,15.1630545],[99.8085938,15.0758305],[99.7371979,15.0161095],[99.7258148,14.9611092],[99.7755432,14.9586086],[99.8788605,14.9194431],[100.02388,14.91222],[100.0397034,14.9547195],[100.1166534,14.9466648],[100.1463776,14.9205532],[100.2112885,14.9549637],[100.2613831,14.9502754],[100.3047028,14.996109],[100.2783203,15.1061077],[100.3091431,15.1674976],[100.2922134,15.2233315],[100.2163773,15.350275],[100.1244202,15.4183311],[100.0579376,15.4196358]]]},"properties":{"shapeName":"Chai Nat Province","shapeISO":"TH-18","shapeID":"36821470B25586803673597","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.519989,15.5588865],[104.5855143,15.567356],[104.5824643,15.6043344],[104.6356221,15.6875545],[104.7400552,15.6561517],[104.8590855,15.5748722],[104.9276119,15.6073771],[104.8924824,15.6530103],[104.9026077,15.7577076],[104.9577607,15.7910663],[104.964908,15.857882],[105.0230391,15.9770179],[105.0046945,16.0198437],[105.0600095,16.0978688],[105.01258,16.189535],[105.014911,16.243842],[104.9776733,16.2792614],[104.8886032,16.2111053],[104.8128433,16.1904564],[104.805542,16.156105],[104.6158066,16.1208305],[104.5633087,16.0794411],[104.5030518,15.9572201],[104.4641571,15.9105549],[104.4246979,15.7249985],[104.4630432,15.6008301],[104.519989,15.5588865]]]},"properties":{"shapeName":"Amnat Charoen Province","shapeISO":"TH-37","shapeID":"36821470B2573550131326","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.3329945,13.7210924],[100.2890176,13.7129235],[100.2441587,13.674474],[100.1816444,13.6890427],[100.1135485,13.6789624],[100.0704508,13.6516902],[100.0435867,13.581665],[100.0377731,13.5044441],[100.0881408,13.4281963],[100.2511194,13.4754634],[100.2753701,13.5065138],[100.3301966,13.4863487],[100.4090046,13.4934205],[100.3755366,13.6188015],[100.3484118,13.6407102],[100.3329945,13.7210924]]]},"properties":{"shapeName":"Samut Sakhon Province","shapeISO":"TH-74","shapeID":"36821470B38183153164097","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.8607551,13.697029],[100.7879663,13.7167233],[100.7099173,13.7169711],[100.6982318,13.6558631],[100.6358068,13.6487187],[100.5794589,13.669285],[100.5175771,13.6672274],[100.5210167,13.6051441],[100.4538767,13.6060269],[100.4533529,13.5041207],[100.6324288,13.5204819],[100.8489758,13.4791495],[100.8794476,13.5480772],[100.9040752,13.5541119],[100.9594134,13.6323244],[100.9557667,13.6623807],[100.8607551,13.697029]]]},"properties":{"shapeName":"Samut Prakan Province","shapeISO":"TH-11","shapeID":"36821470B35369831436030","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.3401674,18.8066022],[100.3624001,18.7488643],[100.3553279,18.6771984],[100.4074936,18.6299973],[100.3852692,18.5677757],[100.4130402,18.4880486],[100.4894333,18.4933281],[100.5419312,18.4547157],[100.5422134,18.4166641],[100.4922028,18.310276],[100.4516602,18.1513863],[100.3980106,18.134907],[100.4125303,18.0194218],[100.5441548,18.0155163],[100.6100832,18.0496061],[100.6927605,18.024194],[100.7727403,18.0268278],[100.8189826,18.0835232],[100.8463433,18.0814783],[100.9143145,18.1594744],[100.919152,18.2420581],[101.085738,18.380277],[101.052563,18.425332],[101.103581,18.521494],[101.183278,18.560522],[101.181924,18.612231],[101.226124,18.624288],[101.272742,18.688609],[101.235603,18.741013],[101.258451,18.808685],[101.245758,18.88864],[101.29671,18.944753],[101.292606,18.98094],[101.355221,19.045628],[101.266172,19.117245],[101.245038,19.181221],[101.259537,19.230795],[101.18704,19.397905],[101.213782,19.475837],[101.266351,19.470363],[101.281111,19.581876],[101.207479,19.605417],[101.19824,19.577002],[101.1228,19.572127],[101.035342,19.628509],[100.934389,19.622329],[100.882987,19.60608],[100.778954,19.492609],[100.651837,19.556321],[100.609302,19.5483],[100.580467,19.498852],[100.482948,19.547559],[100.4841958,19.488411],[100.5719299,19.4677734],[100.5183334,19.4038849],[100.5269318,19.3491631],[100.6030426,19.3327751],[100.5952606,19.2497177],[100.6194305,19.1452751],[100.6085968,19.1052742],[100.5508118,19.0647202],[100.4724884,19.0469437],[100.4485931,19.0005531],[100.4783957,18.9498201],[100.4046457,18.9450935],[100.3401674,18.8066022]]]},"properties":{"shapeName":"Nan Province","shapeISO":"TH-55","shapeID":"36821470B5914712447243","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.7174835,13.1715679],[101.6924667,13.1338882],[101.7121964,13.0344429],[101.7422028,13.0261097],[101.7474823,12.9502754],[101.7861023,12.9508305],[101.8305511,12.8377762],[101.7845184,12.6927105],[101.8628891,12.6418277],[101.9020369,12.6007057],[101.9285198,12.5362359],[101.9972192,12.5357463],[102.0931345,12.4621202],[102.2575661,12.2969948],[102.3048537,12.3008032],[102.3437444,12.3348153],[102.3684347,12.4017188],[102.3254652,12.4275878],[102.3483276,12.4686089],[102.3255463,12.5247211],[102.3516464,12.5855541],[102.3907166,12.5998993],[102.3916626,12.6672211],[102.4444275,12.7302761],[102.5063581,12.7512655],[102.5293542,12.8181526],[102.5011707,12.8498478],[102.5091435,12.8950582],[102.4855418,12.9479824],[102.5015995,12.9922444],[102.4594988,13.0821735],[102.4396112,13.0855614],[102.3931431,13.1772583],[102.3528489,13.297278],[102.2802734,13.2299976],[102.2655487,13.1980534],[102.2141571,13.2055531],[102.1933136,13.2452745],[102.1430511,13.2011089],[102.0513763,13.1588879],[102.0341492,13.1672211],[102.0347137,13.2661095],[101.9952469,13.3361988],[101.7702637,13.1808319],[101.7174835,13.1715679]]]},"properties":{"shapeName":"Chanthaburi Province","shapeISO":"TH-22","shapeID":"36821470B14101604973013","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.2463684,16.8988838],[104.7370029,16.8053846],[104.759386,16.844452],[104.736067,16.91742],[104.729525,17.008272],[104.801488,17.164734],[104.806605,17.240214],[104.799798,17.394542],[104.707817,17.520799],[104.471869,17.651817],[104.355134,17.822603],[104.273448,17.865939],[104.224833,17.98004],[104.187899,18.02484],[104.1244202,18],[104.1330414,17.9019394],[104.1027679,17.8608284],[104.0319366,17.9347191],[103.9863663,17.8647194],[104.0102997,17.8010101],[104.0797119,17.6738853],[104.0302734,17.635273],[104.0494232,17.5791626],[104.0086136,17.4763832],[104.047493,17.3919411],[104.1344299,17.3813858],[104.1574936,17.4111061],[104.2252731,17.404995],[104.3099823,17.4222183],[104.4191437,17.4149971],[104.4433136,17.23666],[104.4013824,17.1744385],[104.3999863,17.1222191],[104.3605804,17.0914841],[104.3919373,17.014164],[104.324707,16.9794388],[104.3105316,16.9411049],[104.2463684,16.8988838]]]},"properties":{"shapeName":"Nakhon Phanom Province","shapeISO":"TH-48","shapeID":"36821470B33387239802611","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.9853023,15.1808448],[98.9611364,15.2288141],[98.8919373,15.2724991],[98.8738708,15.3144436],[98.6445948,15.6375152],[98.558125,15.655036],[98.552899,15.601741],[98.585365,15.471216],[98.580282,15.375598],[98.547719,15.35827],[98.489737,15.393533],[98.39426,15.342747],[98.394189,15.262692],[98.30597,15.313504],[98.300466,15.27486],[98.213371,15.182008],[98.183678,15.096308],[98.231354,15.044423],[98.208301,14.984588],[98.250971,14.915984],[98.253945,14.813849],[98.323582,14.709828],[98.433329,14.614192],[98.491861,14.540626],[98.524209,14.45528],[98.549527,14.433913],[98.59715,14.327643],[98.630497,14.316007],[98.756583,14.21378],[98.834789,14.17203],[98.87915,14.123165],[98.968934,14.080119],[99.020783,13.945622],[99.107729,13.893235],[99.099442,13.838055],[99.121417,13.77303],[99.166965,13.726142],[99.2010956,13.7277756],[99.2580414,13.7688885],[99.3505402,13.75],[99.4433136,13.7774982],[99.4710999,13.8005543],[99.5141602,13.7608318],[99.551651,13.7572212],[99.5922089,13.7958317],[99.6891479,13.8224983],[99.8058167,13.8152752],[99.8799591,13.9444075],[99.8280487,13.996109],[99.8113708,14.0616741],[99.7760925,14.0933304],[99.7627716,14.1652756],[99.8194427,14.2199974],[99.8288727,14.5236092],[99.8863831,14.5497208],[99.8533173,14.5933304],[99.8444366,14.6577759],[99.7844238,14.6772213],[99.6783142,14.7944431],[99.5952606,14.7844429],[99.5435944,14.7969437],[99.5347137,14.7402744],[99.4991455,14.6966648],[99.4333191,14.7211094],[99.4377594,14.7658329],[99.3705292,14.7658329],[99.3280487,14.829443],[99.3125308,14.9673494],[99.2864695,14.9923797],[99.2575201,14.9839368],[99.1313758,15.0460817],[99.075838,15.0351562],[99.0192028,15.0551588],[98.9853023,15.1808448]]]},"properties":{"shapeName":"Kanchanaburi Province","shapeISO":"TH-71","shapeID":"36821470B15979952926255","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.302475,17.6958275],[98.2402649,17.7177734],[98.2108154,17.7708321],[98.2444305,17.8213844],[98.1563721,17.9308281],[98.0861053,17.9966621],[98.0938721,18.0605545],[98.1296997,18.0999985],[98.1038666,18.1488838],[98.0913696,18.23666],[98.1210938,18.3105545],[98.1069336,18.3599968],[98.1672058,18.4361076],[98.1149902,18.5358315],[98.118042,18.6063843],[98.1002655,18.7738876],[98.0808258,18.8236084],[98.1244202,18.9299965],[98.1091461,19.0008316],[98.133606,19.0427742],[98.1158142,19.0808296],[98.1555481,19.1477737],[98.2080383,19.1644402],[98.2474823,19.1419411],[98.3361053,19.0347214],[98.5330505,19.0255547],[98.6160889,19.0424957],[98.64888,19.0666656],[98.6497192,19.1274948],[98.5866547,19.2052727],[98.6080322,19.234993],[98.6058197,19.3566628],[98.5927582,19.3719406],[98.6094208,19.4752731],[98.5763855,19.5138855],[98.5505371,19.6088867],[98.4905396,19.6527748],[98.466728,19.692641],[98.323202,19.698836],[98.248528,19.676379],[98.216636,19.725359],[98.135426,19.788187],[98.083352,19.810341],[98.034416,19.805544],[98.025904,19.637372],[97.975041,19.602046],[97.861743,19.57583],[97.87289,19.495754],[97.80331,19.40483],[97.798801,19.35273],[97.825475,19.32416],[97.809433,19.253262],[97.841742,19.225017],[97.835698,19.093129],[97.764367,19.066038],[97.738688,19.035317],[97.738324,18.978735],[97.692277,18.948555],[97.696942,18.904646],[97.736889,18.886979],[97.739121,18.829819],[97.77402,18.68919],[97.77206,18.597809],[97.751285,18.576169],[97.651101,18.577232],[97.604825,18.53702],[97.443973,18.49501],[97.403248,18.566724],[97.3462187,18.5461024],[97.3867695,18.5054293],[97.4441063,18.3403469],[97.5018183,18.2674396],[97.5512102,18.33408],[97.6202298,18.317873],[97.6394601,18.2876689],[97.6136647,18.2249122],[97.6759004,18.1617079],[97.6772296,18.1149783],[97.7364896,17.9865698],[97.6719802,17.8824569],[97.7034879,17.8156378],[97.7752123,17.7511023],[97.7937858,17.6793608],[97.9116428,17.5667808],[97.9910889,17.5908318],[98.1158142,17.6449966],[98.2022095,17.6463852],[98.2377625,17.6819382],[98.302475,17.6958275]]]},"properties":{"shapeName":"Mae Hong Son Province","shapeISO":"TH-58","shapeID":"36821470B62325531860543","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.9952469,13.3361988],[102.0347137,13.2661095],[102.0341492,13.1672211],[102.0513763,13.1588879],[102.1430511,13.2011089],[102.1933136,13.2452745],[102.2141571,13.2055531],[102.2655487,13.1980534],[102.2802734,13.2299976],[102.3528489,13.297278],[102.3450807,13.3468871],[102.3635467,13.5050397],[102.3338478,13.5407253],[102.3666486,13.5779383],[102.4388442,13.559799],[102.5240916,13.5648918],[102.5701021,13.585849],[102.5628418,13.6839824],[102.688584,13.7519276],[102.7662519,13.8563771],[102.7829104,13.9328165],[102.8666326,14.0042587],[102.9076658,14.017262],[102.9007269,14.0834129],[102.9357589,14.1680932],[102.8777618,14.1524982],[102.8244324,14.1674976],[102.7224884,14.1316643],[102.6116486,14.1338882],[102.5941467,14.1486092],[102.5388794,14.1208305],[102.4274673,14.1572208],[102.364151,14.1580544],[102.3027573,14.1277752],[102.23526,14.1383324],[102.1955338,14.1880531],[102.1658173,14.1372204],[102.1197128,14.1438885],[101.9877625,13.5872192],[101.9983215,13.5],[101.9455338,13.4819412],[101.9952469,13.3361988]]]},"properties":{"shapeName":"Sa Kaeo Province","shapeISO":"TH-27","shapeID":"36821470B21581797364135","shapeGroup":"THA","shapeType":"ADM1"}},
|
||||||
|
{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[99.6941572,6.5320381],[99.678341,6.6256183],[99.6392867,6.6768192],[99.5965816,6.5967432],[99.6604685,6.5049256],[99.6941572,6.5320381]]],[[[100.0463562,7.0633326],[99.9988556,7.059721],[99.9183044,7.1388884],[99.8955231,7.0791664],[99.8566284,7.0724993],[99.8113556,7.145833],[99.7341475,7.1291644],[99.6695946,7.0412272],[99.6856643,6.894631],[99.6981824,6.8658138],[99.7853982,6.8317118],[99.8159691,6.7700875],[99.8427345,6.7768011],[99.8769629,6.7208636],[99.9039606,6.7223532],[99.9349575,6.6573011],[99.9651161,6.6368707],[100.0048058,6.5653105],[100.0529849,6.519023],[100.0953919,6.5424186],[100.1203879,6.4227403],[100.159705,6.487414],[100.153772,6.557375],[100.180683,6.654281],[100.171463,6.693389],[100.205056,6.726082],[100.1924591,6.7858324],[100.2197037,6.8652763],[100.1960831,6.9038877],[100.1219025,6.9294434],[100.0810928,6.974721],[100.0463562,7.0633326]]]]},"properties":{"shapeName":"Satun Province","shapeISO":"TH-91","shapeID":"36821470B18098062997541","shapeGroup":"THA","shapeType":"ADM1"}}
|
||||||
|
]}
|
||||||
58
public/maps/usa-adm1.geojson
Normal file
58
public/maps/usa-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
11
public/maps/zaf-adm1.geojson
Normal file
11
public/maps/zaf-adm1.geojson
Normal file
File diff suppressed because one or more lines are too long
BIN
public/seasonal/honey-peach-pixel.png
Normal file
BIN
public/seasonal/honey-peach-pixel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
50
scripts/start-local.mjs
Normal file
50
scripts/start-local.mjs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
|
const cacheFile = resolve(
|
||||||
|
projectRoot,
|
||||||
|
"node_modules/vinext/dist/server/static-file-cache.js",
|
||||||
|
);
|
||||||
|
const originalLine = 'const pathname = "/" + relativePath;';
|
||||||
|
const windowsSafeLine =
|
||||||
|
'const pathname = "/" + relativePath.replaceAll("\\\\", "/");';
|
||||||
|
|
||||||
|
if (!existsSync(cacheFile)) {
|
||||||
|
console.error("未找到项目依赖,请先执行 npm install。");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheSource = readFileSync(cacheFile, "utf8");
|
||||||
|
if (cacheSource.includes(originalLine)) {
|
||||||
|
writeFileSync(
|
||||||
|
cacheFile,
|
||||||
|
cacheSource.replace(originalLine, windowsSafeLine),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
console.log("已应用 Windows 静态资源路径兼容处理。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = spawnSync(npmCommand, ["run", "build"], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (build.status !== 0) {
|
||||||
|
process.exit(build.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n整个模板项目正在启动:http://localhost:3000/\n");
|
||||||
|
const server = spawn(npmCommand, ["run", "start", "--", "--port", "3000"], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("exit", (code) => process.exit(code ?? 0));
|
||||||
|
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||||
|
process.on(signal, () => server.kill(signal));
|
||||||
|
}
|
||||||
@@ -14,13 +14,13 @@ async function render() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("server-renders the OrbitFlow admin template", async () => {
|
test("server-renders the WBBR admin template", async () => {
|
||||||
const response = await render();
|
const response = await render();
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
assert.match(html, /OrbitFlow/);
|
assert.match(html, /WBBR/);
|
||||||
assert.match(html, /下午好,林知夏/);
|
assert.match(html, /下午好,林知夏/);
|
||||||
assert.match(html, /流程泳道/);
|
assert.match(html, /流程泳道/);
|
||||||
assert.match(html, /定时任务/);
|
assert.match(html, /定时任务/);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Cloudflare hosting adapter for the frontend-only OrbitFlow template. */
|
/** Cloudflare hosting adapter for the frontend-only WBBR style template. */
|
||||||
import {
|
import {
|
||||||
handleImageOptimization,
|
handleImageOptimization,
|
||||||
DEFAULT_DEVICE_SIZES,
|
DEFAULT_DEVICE_SIZES,
|
||||||
|
|||||||
Reference in New Issue
Block a user