Update WBBR frontend template collection

This commit is contained in:
Codex
2026-08-27 20:15:24 +08:00
parent bd4d54a110
commit e444605e5d
85 changed files with 6322 additions and 115 deletions

51
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# dependencies and local caches
/node_modules
# Dependencies and package-manager caches
/node_modules/
/.npm-cache/
/.pnp
.pnp.*
@@ -9,26 +9,49 @@
!.yarn/releases
!.yarn/versions
# tests and builds
/coverage
# Build, framework and test output
/.next/
/.vinext/
/out/
/dist/
/.wrangler/
/out/
/coverage/
/outputs/
/work/
*.tsbuildinfo
next-env.d.ts
# local files
.DS_Store
# Cloudflare and deployment state
/.wrangler/
/.vercel/
# Environment files and local secrets
.env
.env.*
!.env.example
!.env.*.example
*.pem
*.starter
*.pre-tsbuild
# Runtime and debug logs
*.log
*.log.*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.env*
.vercel
next-env.d.ts
pnpm-debug.log*
# Editors and operating systems
/.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
View File

@@ -1,79 +1,197 @@
# OrbitFlow React 后台模板
# WBBR · AI 前端样式模板工程
一个无需后端即可运行的现代后台管理模板。项目基于 React 19、Next.js 与 vinext所有演示业务数据来自本地 JSON定时任务、审批流程和工作列表修改通过 `localStorage` 保存在当前浏览器
WBBR 是一套用于在项目内为 AI 定义、展示和复用前端样式的 React 模板工程。它通过可直接运行的真实页面,向 AI 提供明确的布局、颜色、组件、交互和响应式参考,减少生成页面时的视觉偏差。仓库内的演示内容由本地 JSON 提供,无需后端即可作为 AI 编码的前端样式基准
## 已包含
## 项目特点
- 深色多级侧边菜单与移动端抽屉导航
- 标签、按钮、表单、状态反馈与菜单样式
- 图片文字列表和响应式卡片视图
- 支持拖放的四列流程泳道
- 定时任务列表、启停开关和新建配置弹窗
- 类飞书的审批流程设计器
- 项目、里程碑、任务、子任务多级工作表格
- 全局搜索、批量选择、筛选、Toast 等交互
- 纯前端运行:无需数据库、接口服务或登录系统
- 模板集中导航:首页统一展示并搜索全部页面模板
- 本地模拟数据:业务数据集中保存在 `app/data` 目录
- 独立页面路由:每个模板可单独浏览、修改和复用
- 完整交互演示:包含拖拽、筛选、编辑、高亮、预览和本地保存
- 响应式布局:兼容桌面端、平板和移动端尺寸
- 易于接入后端:保留现有组件,只需替换 JSON 数据读取方式
- 面向 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`,或点击后台首页右下角“审批流程设计”入口。设计器包含:
## 技术栈
- 左侧审批、条件分支和抄送节点库
- 节点点击添加及拖入画布
- 审批条件双分支与合流展示
- 单人审批、或签、会签规则
- 审批人、表单权限与高级规则配置
- 流程校验、模拟预览、JSON 查看与复制
- 自动保存、恢复示例及本地发布状态
- React 19
- Next.js 16 页面结构
- vinext / Vite 8 构建与运行
- TypeScript 5
- CSS Modules
- 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
npm install
npm run dev
npm run dev -- --port 3000
```
生产构建与测试:
### 构建与检查
```bash
npm run build
npm run lint
npm test
```
## 数据位置
## 项目结构
- `app/data/mock-data.json`:概览、项目、泳道和定时任务
- `app/data/approval-flow.json`:审批流程、条件分支和节点配置
- `app/data/work-list.json`:树形工作项及层级关系
```text
WBBR/
├─ 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`:后台主界面和交互逻辑
- `app/worklist/page.tsx`:多级工作列表逻辑
- `app/worklist/WorkList.module.css`:树形表格视觉样式
- `app/approval/page.tsx`:审批流程设计器逻辑
- `app/approval/ApprovalDesigner.module.css`:设计器视觉与布局
- `app/globals.css`:后台视觉系统与响应式样式
地图边界文件位于 `public/maps`,其中包括国家一级行政区数据和河北省市级行政区示例数据。
## 注意
## 数据保存方式
所有新增和状态修改只写入浏览器本地存储。清除浏览器站点数据后会恢复为 JSON 中的初始内容。
本项目暂时不依赖后端:
- 初始演示内容来自本地 JSON
- 页面编辑结果按功能保存在浏览器 `localStorage`
- 清除浏览器站点数据后,页面会恢复到 JSON 初始状态
- `.openai/hosting.json` 中的 D1 和 R2 均为 `null`
## 接入真实接口
接入后端时建议保留页面组件和类型定义,只替换数据层:
1. 将 JSON 导入替换为 `fetch` 或项目统一请求客户端。
2.`localStorage` 保存逻辑替换为新增、修改和删除接口。
3. 为加载、失败和空数据状态接入真实返回结果。
4. 保持现有数据字段,或在接口层增加字段转换函数。
## 适用场景
- 后台管理系统原型
- 产品需求与交互演示
- 前端页面模板选型
- 无后端条件下的业务验证
- React / TypeScript 页面开发参考
- 新项目的管理端或内容端基础工程

View File

@@ -105,7 +105,7 @@ export default function Home() {
<aside className={`sidebar ${sidebarOpen ? "is-open" : ""}`}>
<button className="brand" onClick={() => navigate("overview")} aria-label="返回概览">
<span className="brand-mark"><i /><i /><i /></span>
<span>Orbit<span>Flow</span></span>
<span>WBBR<span>UI</span></span>
</button>
<nav className="main-nav" aria-label="主导航">
{navGroups.map((group) => (
@@ -155,7 +155,7 @@ export default function Home() {
<section className="page-content">
<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>
{activeView === "overview" && <Overview onNavigate={navigate} notify={notify} />}

File diff suppressed because one or more lines are too long

123
app/TemplateHub.tsx Normal file
View 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 === "内容资料" ? "列表、文档与内容展示" : "数据、关系与辅助工具";
}

View 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
View 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>;
}

View 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
View 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>
);
}

File diff suppressed because one or more lines are too long

222
app/calendar/page.tsx Normal file
View 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;
}

File diff suppressed because one or more lines are too long

107
app/character/page.tsx Normal file
View 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
View 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": []
}
]
}

View 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
View 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" }
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

112
app/data/media-player.json Normal file
View 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
View 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": "南部温带农业区,以大豆、水稻和葡萄酒产业著名。" }
]
}
]
}

View 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": []
}
]
}

View 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
View 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
View 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", "羊", "山羊", "宠物羊"] }
]
}

View 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
View 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> HTMLCSS 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 SymbolArial 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}`;
}

File diff suppressed because one or more lines are too long

View 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
View 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`;
}

View File

@@ -9,27 +9,23 @@ export async function generateMetadata(): Promise<Metadata> {
const origin = `${protocol}://${host}`;
return {
title: "OrbitFlow · React 后台模板",
description: "包含组件样式、内容列表、流程泳道和定时任务的纯前端 React 后台模板。",
title: "WBBR · AI 前端样式模板工程",
description: "用于在项目内为 AI 定义布局、组件、交互和响应式规则的 React 前端样式模板工程。",
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
openGraph: {
title: "OrbitFlow · React 后台模板",
description: "纯前端、JSON 数据驱动的现代后台模板。",
images: [{ url: new URL("/og.png", origin).toString(), width: 1200, height: 630, alt: "OrbitFlow 后台模板" }],
title: "WBBR · AI 前端样式模板工程",
description: "通过可运行页面为项目内 AI 提供统一的前端视觉与交互基准。",
images: [{ url: new URL("/og.png", origin).toString(), width: 1200, height: 630, alt: "WBBR AI 前端样式模板工程" }],
},
twitter: {
card: "summary_large_image",
title: "OrbitFlow · React 后台模板",
description: "组件、泳道、定时任务,一套可直接使用的纯前端后台模板。",
title: "WBBR · AI 前端样式模板工程",
description: "后台、内容与可视化页面样式基准,全部使用本地 JSON 演示。",
images: [new URL("/og.png", origin).toString()],
},
};
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
return <html lang="zh-CN"><body>{children}</body></html>;
}

File diff suppressed because one or more lines are too long

185
app/markdown/page.tsx Normal file
View 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("`", "代码", "`")}>&lt;/&gt;</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>;
});
}

View 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}}

File diff suppressed because one or more lines are too long

View 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;
}

View 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}}

View File

@@ -0,0 +1,5 @@
import MediaPlayerExperience from "./MediaPlayerExperience";
export default function VideoPlayerPage() {
return <MediaPlayerExperience mode="video" />;
}

View File

@@ -0,0 +1,5 @@
import MediaPlayerExperience from "../media-player/MediaPlayerExperience";
export default function MusicPlayerPage() {
return <MediaPlayerExperience mode="music" />;
}

View File

@@ -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}}

View File

@@ -1,34 +1,5 @@
import Link from "next/link";
import AdminApp from "./AdminApp";
import styles from "./page.module.css";
import TemplateHub from "./TemplateHub";
// Feature routes use localStorage-backed JSON demo data.
export default function Home() {
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>
</>
);
export default function HomePage() {
return <TemplateHub />;
}

File diff suppressed because one or more lines are too long

266
app/produce-map/page.tsx Normal file
View 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;
}

View 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; }
}

View 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>JPGPNGWEBP · 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`;
}

File diff suppressed because one or more lines are too long

105
app/simple-list/page.tsx Normal file
View 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>
);
}

File diff suppressed because one or more lines are too long

View 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
View 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
View File

@@ -1,13 +1,15 @@
{
"name": "orbitflow-admin-template",
"name": "wbbr",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "orbitflow-admin-template",
"name": "wbbr",
"version": "0.1.0",
"dependencies": {
"@types/d3-geo": "^3.1.1",
"d3-geo": "^3.1.1",
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6"
@@ -2652,6 +2654,14 @@
"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": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -2683,6 +2693,11 @@
"dev": true,
"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": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -4296,6 +4311,28 @@
"dev": true,
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -5768,6 +5805,14 @@
"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": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",

View File

@@ -1,5 +1,5 @@
{
"name": "orbitflow-admin-template",
"name": "wbbr",
"version": "0.1.0",
"private": true,
"engines": {
@@ -9,10 +9,13 @@
"dev": "vinext dev",
"build": "vinext build",
"start": "vinext start",
"start:local": "node scripts/start-local.mjs",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next"
},
"dependencies": {
"@types/d3-geo": "^3.1.1",
"d3-geo": "^3.1.1",
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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"}}
]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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"}}
]}

View 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"}}
]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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"}}
]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

3
run.cmd Normal file
View File

@@ -0,0 +1,3 @@
npm run build
npm run start -- --port 3000

50
scripts/start-local.mjs Normal file
View 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));
}

View File

@@ -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();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, /OrbitFlow/);
assert.match(html, /WBBR/);
assert.match(html, /下午好,林知夏/);
assert.match(html, /流程泳道/);
assert.match(html, /定时任务/);

View File

@@ -1,4 +1,4 @@
/** Cloudflare hosting adapter for the frontend-only OrbitFlow template. */
/** Cloudflare hosting adapter for the frontend-only WBBR style template. */
import {
handleImageOptimization,
DEFAULT_DEVICE_SIZES,