# Draw the World（Country + City）— 产品与技术 Spec

参考：[geographygames.net/draw-country](https://www.geographygames.net/draw-country)（主要参考，机制描述完整）、[country-draw.vercel.app](https://country-draw.vercel.app/)（同类玩法，页面为 SPA 未取到细节，仅作立意参考）。

> v0.2 迭代：在原有"画国家"基础上加了一个并列的"画城市"数据集，两者共享同一套引擎（画布/评分/三种模式），只是 target 的经纬度轮廓来源不同。见 §8。

## 1. 核心玩法

单局流程（参考站点每局约 30 秒）：

1. 屏幕上方显示一个国家名（**不显示任何地图/参考图**）。
2. 玩家在下方空白画布上，用**一笔连续的笔画**画出这个国家的轮廓。
3. 点击 Submit 提交。
4. 系统给出 **0–100 分**评分 + 一个评级称号，并叠加显示"标准轮廓 vs 玩家笔画"的对比图。

**评分只关心形状，不关心大小和位置**——玩家画多大、画在画布哪个位置都不影响分数；但要求朝向"上北下南"，允许 **±15°** 的小幅倾斜容差，超出容差的旋转会拉低分数（不做旋转校正）。

## 2. 游戏模式

| 模式 | 规则 |
|---|---|
| **Daily Challenge** | 按 UTC 日期确定性选一个国家，全站当天所有人题目相同；每人每天限 1 次提交；结果存 `localStorage`，刷新页面不能重来；提供"复制成绩卡片到剪贴板"分享。 |
| **Singleplayer** | 从全部国家里随机出题，连续 20 局内不重复；无限局数；记录本轮 streak（分数 ≥60 计入连胜）和历史最高分。 |
| **Pick a Country** | 搜索框按名称过滤，或按大洲（Asia/Europe/Africa/Americas/Oceania）浏览列表，任选一个国家反复练习，不计入 Daily/Singleplayer 的统计。 |

三种模式共享同一个"出题 → 画 → 提交 → 评分反馈"组件。

## 3. 数据来源

- 国家轮廓：Natural Earth 110m 精度（经 `world-atlas@2` 的 TopoJSON 转出），**构建期**用 Node 脚本（`scripts/build-data.mjs`）转成一份扁平的 `data/countries.json`，运行时**不依赖任何 CDN 或后端**。
- 每个国家取面积最大的最多 3 块陆地（处理群岛型国家如印尼、菲律宾），丢弃过小的碎片以控制评分计算量。
- 大洲信息来自 ISO 3166 官方区域表（`lukes/ISO-3166-Countries-with-Regional-Codes`），少数无 ISO 码的争议地区（科索沃、北塞浦路斯、索马里兰）直接从题库剔除；台湾手动修正为 Asia（ISO 表里因政治原因留空）。
- 最终题库：**173 个国家**（Asia 46 / Europe 38 / Africa 51 / Americas 31 / Oceania 7）。

## 4. 评分算法

参考站点的描述："area overlap (IoU) + line tracing distance 的混合分数"。实现如下：

1. **投影**：把目标国家的经纬度轮廓做以国家中心纬度为基准的等距圆柱投影修正（`x' = lon·cos(centerLat)`，`y' = lat`），弱化中高纬度国家东西向被拉伸的问题。
2. **归一化**：目标轮廓和玩家笔画分别按各自包围盒的最长边缩放到统一尺度，并以几何中心对齐——完全消除大小和位置的影响。
3. **旋转搜索**：在 **-15°~+15°**（步长 2°）内网格搜索玩家笔画的最佳旋转角，取该窗口内使分数最高的角度；超出这个窗口的偏转找不到更好的对齐，分数自然被拉低，等价于"容差内免罚、容差外递减"。
4. **面积重合度（IoU）**：目标轮廓和"收尾闭合后的玩家笔画"分别在一张 128×128 的栅格上做多边形填充，计算 `|交集| / |并集|`。
5. **描边贴合度（Line distance）**：把目标国家边界和玩家笔画都离散成点集，计算双向最近点平均距离（Chamfer distance），归一化后转成 0–1 的"贴合度"。
6. **混合分数**：`score = round(100 × (0.55 × IoU + 0.45 × 贴合度))`，权重集中在 `js/scoring.js` 顶部常量里，方便调参。
7. **评级**（0–100 分 5 档，参考站点给出了首尾两档 "Try again" / "Cartographer"，中间三档为按等距区间合理插值）：

   | 分数区间 | 评级 |
   |---|---|
   | 0–19 | Try again |
   | 20–39 | Not bad |
   | 40–59 | Good |
   | 60–79 | Great |
   | 80–100 | Cartographer |

## 5. 交互细节

- 输入：`pointerdown/move/up`，同时兼容鼠标和触摸；`touch-action: none` 防止移动端滚动干扰画画。
- 笔画规则：从落笔到抬笔算一整笔；抬笔后 Submit 按钮才可点击；点数或路径长度太短（防误触/单点提交）时 Submit 保持禁用。
- Clear 按钮：提交前可无限次清空重画（Daily Challenge 也允许重画，只有 **Submit 后** 才计入当天成绩）。
- 提交后：画布叠加显示目标国家的标准轮廓（半透明）与玩家笔画对比，展示 IoU / 贴合度两个子分数 + 总分 + 评级。
- 无需注册账号，所有状态存 `localStorage`，按数据集（`country`/`city`）分命名空间，互不干扰：
  - `dc_<dataset>_daily_<YYYY-MM-DD>`：当天题目 + 是否已玩 + 分数
  - `dc_<dataset>_recent`：Singleplayer 最近出过的 id（滑动窗口 20）
  - `dc_<dataset>_stats`：累计局数、历史最高分、当前连胜

## 6. 技术栈

纯静态站点，零运行时依赖：

- `index.html` + `css/style.css` + `js/*.js`（ES Modules，浏览器原生 `<script type="module">`，不需要打包器）
- 数据构建：`npm run build:data`（国家，依赖 `topojson-client`）、`npm run build:cities`（城市，依赖 Nominatim API），都只在开发机跑一次，产物 `data/countries.json` / `data/cities.json` 直接提交/部署
- 本地预览：`npm run serve`（`python3 -m http.server`）或任意静态文件服务器

## 7. 已知简化 / 后续可迭代

- 经纬度投影是粗略近似，跨越极大纬度范围的国家（如智利、挪威）形状会有一定失真，属于已知取舍。
- 群岛国家只保留最大 3 块陆地，评分只针对这些主岛。
- 未做排行榜/账号系统（参考站点 Daily Challenge 有全球排行榜，这里先只做本地成绩）。

## 8. 迭代：Draw the City

在顶部新增一个"数据集"切换（🌍 Countries / 🏙️ Cities），下面仍是原来那三个模式（Daily / Singleplayer / Pick）。**画布、评分算法（§4）、交互细节（§5）完全复用**——因为两个数据集导出的都是同一种 `{id, name, region, bbox, rings}` 结构，`scoring.js`/`geometry.js`/`canvas-draw.js` 不关心 target 是国家还是城市。改动只在两处：

1. `js/datasets.js`：把原来写死的 `loadCountries()` 泛化成 `loadDataset(name)`，按 `data/countries.json` / `data/cities.json` 两个文件加载。
2. `js/storage.js` / `js/main.js`：所有 localStorage key、Daily 的确定性选题 hash、Singleplayer 的最近出题窗口，都按 `dataset` 参数分开命名空间，两个游戏的进度完全独立。

### 8.1 数据来源与构建

国家用的是 Natural Earth 这种"发布好的、覆盖全球的行政边界数据集"，但**城市没有对应的全球统一数据集**——市界定义在各国差异很大（有的城市"市"只是市中心一小块行政区，有的是包含郊区的都会区）。因此城市数据改用 **OpenStreetMap（经 Nominatim 搜索 API）逐个查询**：

- `scripts/city-list.mjs`：人工精选 **54 个知名城市**，横跨五大洲，每条记录带 `query`（消歧义查询串，如 `"Chicago, Illinois, United States"`）和 `countryCode`（预期 ISO 国家码，用于过滤"重名但在别的国家"的错误匹配，比如 "Copenhagen" 曾经匹配到纽约州一个同名小村庄）。
- `scripts/build-cities.mjs`：对每条记录调用 Nominatim `/search`（`polygon_geojson=1&polygon_threshold=0.0003` 保留较高精度，`accept-language=en` 强制英文名），按"行政边界 relation + 国家匹配 > 任意多边形 + 国家匹配 > 行政边界 relation（任意国家）> 任意多边形"的优先级选结果，**再经过 §8.3 的陆地掩膜裁剪**，转成和 `countries.json` 一样的 schema，写入 `data/cities.json`。
- 遵守 Nominatim 使用政策：请求间隔 1.1 秒、带描述性 User-Agent、总量约 55 次，属于个人项目的一次性构建，不是线上高频调用；**生产环境如果要扩大城市库或提高调用频率，应该换成自建 Nominatim/Photon 或付费地理编码服务**，不要用公共 Nominatim 做线上实时查询。
- 少数城市（Copenhagen、Athens、Cape Town、Mumbai 等）的官方行政边界在 OSM 里挂在正式名称下（如 `Municipality of Athens`、`Mumbai City district`），需要手工调整查询串或做展示名覆盖（`build-cities.mjs` 里的 `NAME_OVERRIDES`）；个别城市（如 Manila）Nominatim 搜不到可用的边界关系，换成了同国家的备选城市（Quezon City）。
- 命令：`npm run build:cities`（一次性，首次运行会自动下载 Natural Earth 陆地掩膜到 `raw-data/ne_10m_land.geojson`，~10MB，不进 git；产物 `data/cities.json` 直接提交/部署，运行时不再依赖 Nominatim 或掩膜文件）。

### 8.2 已知限制（城市专属）

- **"市界"口径不统一**：北京、东京这类查到的是整个市/都级行政区（面积巨大、形状偏方正），而悉尼、墨尔本查到的是市中心那个很小的行政区（City of Sydney/City of Melbourne LGA），不是都会区范围——同样叫"画xx市"，两种城市的"正确答案"直觉上完全不是一个尺度。这是 OSM 数据本身的行政区划差异，短期没有完美解法，只能接受。
- **版权**：城市边界数据来自 OpenStreetMap，遵循 [ODbL](https://www.openstreetmap.org/copyright)，页脚已加署名；陆地掩膜来自 Natural Earth（公共领域）。
- 城市库比国家库小很多（54 vs 173），Daily/Singleplayer 的重复概率更高；后续要扩充城市库，往 `scripts/city-list.mjs` 加条目、重跑 `npm run build:cities` 即可，代码不用改。

### 8.3 踩坑记录：行政边界包含海域，导致沿海城市形状失真

**问题**：用户实测发现纽约的形状不对——没有史泰登岛的独立陆地、没有牙买加湾的缺口、没有洛克威半岛。排查发现：OSM 里 "New York" 这个行政边界关系（relation 175905）画的是纽约市的**法定行政管辖范围**，这个范围延伸进了纽约港的开阔水域。用点在多边形内测试验证：下纽约湾（Lower New York Bay）离岸约 6 公里的纯海面坐标落在多边形**内部**。抽查了城市库里其他沿海城市，同样问题出现在至少 8/12 个抽查样本上（波士顿、香港、新加坡、温哥华、阿姆斯特丹、伊斯坦布尔、开普敦、奥克兰等），是系统性问题，不是纽约个例。且验证过这不是简化算法的锅——用未简化的 492 点原始精度渲染出来是同一个团块。

**修复**：引入 Natural Earth 的全球陆地掩膜（`ne_10m_land`，10m 精度），对每个城市的行政边界多边形做"和陆地取交集"的几何裁剪（`@turf/turf` 的 `bboxClip` + `intersect` + `union`），只保留真正是陆地的部分，海域部分被裁掉。裁剪后一个城市经常会分裂成多块独立陆地（`MAX_RINGS_KEPT` 从 3 提到 6），比如纽约裁出来是 4 块（史泰登岛/曼哈顿/布朗克斯/布鲁克林+皇后区+洛克威半岛），香港裁出来是 6 块（新界+九龙半岛/香港岛/大屿山/其他离岛）——形状立刻变得可辨认。

裁剪之后重新做自适应简化（`turf.simplify`，起始容差 0.0006°，如果某块陆地简化后仍超过 220 个点就把容差翻倍重试，最多 6 轮），避免像北京、利马这种海岸线复杂的城市简化前冲到 2000+ 个点，最终城市库总大小和裁剪前基本持平（~212KB）。

**踩过的一个实现坑**：`turf.bboxClip` 对一个跨越全球的 MultiPolygon 按小 bbox 裁剪后，结果里混进大量空的 `[]` 子多边形（那些完全在 bbox 外的子多边形），这本身不是合法的 GeoJSON Polygon（一个 Polygon 至少需要一个 ≥4 个点的环），直接传给 `turf.intersect` 会整体抛错 `Input geometry is not a valid Polygon or MultiPolygon`，导致所有 54 个城市全部裁剪失败。修法是裁剪后先过滤掉空/退化的子多边形，再传给 `intersect`。
