refactor: consume external road compiler

This commit is contained in:
2026-08-26 09:23:46 +08:00
parent b78cb6e990
commit 5cde08090e
48 changed files with 257 additions and 15626 deletions

View File

@@ -18,6 +18,7 @@
| 改复合路口几何(`buildComplexJunctionGeometry`、车道控制避让、人行道转角) | [CLI 与阶段](./cli-and-stages.md#可编辑信号设施与运行时锚点的跨阶段消费) 的复合路口小节 |
| 改预览页生成 | [../preview/](../preview/index.md) |
| 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) |
| Native road compiler CLI、输入或完成标记 | [编译器契约指针](./native-road-package.md) |
---

View File

@@ -0,0 +1,9 @@
# Native Road Compiler Contract
The native-road package is maintained in the private `road-compiler` repository:
`https://git.app.que01.top/que01/road-compiler`
This host consumes the exact git tag recorded in `package.json` and lockfile. The host owns area configuration normalization and writes `RoadCompilerInput`; the compiler owns the `native-road-package/v1` file contract and CLI. Production code must invoke the installed compiler CLI through `scripts/lib/road-compiler-cli.js`, not import compiler source files or read `packages/road-compiler`.
The completion marker is `NATIVE_ROAD_COMPILE_DONE <json>`. `build-area.js` validates the marker's count, JSON payload, area id, output path, process status, and signal before starting Blender.

View File

@@ -1 +1,4 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file":".trellis/spec/pipeline/external-tools.md","reason":"检查 CLI 子进程错误、signal 与日志处理是否符合宿主管线约定。"}
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"检查编译阶段、完成标记和 stage manifest 的跨层影响。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"检查配置、Node pipeline、Blender 消费链的边界没有漂移。"}
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"检查两个有效区域的严格基线 parity。"}

View File

@@ -0,0 +1,44 @@
# Phase 2独立仓库与 CLI 边界设计
## Architecture
P1 已将实现收敛到 `packages/road-compiler/`。P2 把该目录以保留历史的方式导出为独立 package 仓库,保留两个明确方向的边界:
```
宿主 area config
-> scripts/lib/area-config.js
-> RoadCompilerInput JSON宿主 staging
-> @osm-asset/road-compiler CLI子进程
-> native-road/ + NATIVE_ROAD_COMPILE_DONE
-> build-area.js / Blender / preview
```
编译器不知道区域配置、宿主仓库、Blender 或宿主输出目录约定以外的输入路径。宿主不知道 compiler 内部源文件;它只拥有输入 JSON、CLI 的完成标记和写出的契约文件。
## CLI Contract
- CLI 接收一个 JSON 文件中的 `RoadCompilerInput`,而不是宿主的 area config。
- `comparisonDir` 继续作为可选输入,以维持现有 `comparison.json` 产物和 P0 baseline 的可比性。
- 成功只打印一次 `NATIVE_ROAD_COMPILE_DONE <JSON>`。payload 必须至少含 `areaId``output``roads``endpoints``diagnostics`
- `build-area.js` 保持同步批处理模式,子进程日志对用户可见;为了验证标记,输出同时被捕获或 tee。它对启动失败、非零状态、signal、缺失/重复/非法标记和 payload 与当前输入不一致都报错。
- 产物先由 compiler 的现有 staging/atomic promotion 逻辑写入目标 `native-road/`;宿主不接管这一写入过程。
## Repository Contents
独立仓库拥有compiler source、public API、CLI、check、workbench、fixture、两个 parity baseline、契约文档、README、package lockfile 和 workbench 的 `ol` 依赖。测试和 workbench 均必须在没有宿主 checkout 的环境运行。
宿主保留area-config 读取/归一化、`RoadCompilerInput` 映射、pipeline 阶段编排、Blender/preview、区域配置、运行产物和一份升级回归 baseline。宿主 spec 仅保留指针,契约正文随 compiler 仓库维护。
## History Extraction
导出只允许 `git subtree split``git filter-repo`。必须在独立仓库验证 `git log --follow src/compile/native-road.js`;验证的是迁移前的逻辑历史,而非仅 P1 的边界移动提交。导出和验证先在临时分支/clone 完成,外部远端创建和 push 必须使用用户给出的地址和授权范围。
## Dependency and Rollout
初始发布源定为私有 `https://git.app.que01.top/que01/road-compiler.git`。仓库以带注释 tag `v0.1.0` 首发,宿主以该精确 tag 作为依赖并写入 lockfile。开发临时形态可以指向独立 checkout 的本地 `file:` 依赖;它不能成为验收时的唯一消费方式。两个区域 parity 通过后删除 `packages/road-compiler/`;回滚由 revert 宿主消费改造实现,不能让 production host 继续直接 import 该目录。
## Compatibility and Risks
这是位置和调用方式迁移,不改变 compiler 行为。两个区域的 strict content/order hash 是主要 oracle信号文档的确定性生成仍应通过 parity 覆盖。P2 不处理 `check:area` 的历史 stage-manifest freshness warning也不把 Blender/Metal 沙箱问题归因为道路产物。
若外部 CLI 无法及时使用,回滚宿主消费 commit 即可;外部仓库历史和 tag 不需要删除。对于完整 Blender 构建macOS Metal 初始化必须在提权的宿主环境运行。

View File

@@ -1 +1,4 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file":".trellis/spec/pipeline/external-tools.md","reason":"CLI 子进程必须沿用管线的启动、日志和失败状态语义。"}
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"编译是 Blender 前的 pipeline 输入阶段,需保持阶段与 stdout 契约。"}
{"file":".trellis/spec/config/index.md","reason":"宿主拥有区域配置归一化及 RoadCompilerInput 映射。"}
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"本阶段声称产物不变,必须用严格 parity 验证。"}

View File

@@ -1,74 +1,55 @@
# Phase 2 执行计划
## Step 1 — 前置
执行前提:用户已在最新 P2 规划摘要后明确批准。发布目标已定为私有 `https://git.app.que01.top/que01/road-compiler.git`,首发 tag 为 `v0.1.0`
- [ ] Phase 1 的 AC1.1AC1.8 全绿
- [ ] 定档 §「待决策」四项(仓库名 / 托管 / 分发 / 过渡期策略)
## 1. 导出并核验历史
## Step 2 — 拆出仓库(保历史)
- [ ] 创建临时 export 分支或临时 clone使用 `git subtree split --prefix=packages/road-compiler``git filter-repo --path packages/road-compiler/ --path-rename packages/road-compiler/:` 导出。
- [ ] 在导出仓库验证 `git log --follow src/compile/native-road.js` 包含 2026-08-13 以来的 native compiler 历史。
- [ ] 在私有 `https://git.app.que01.top/que01/road-compiler.git` 创建并推送独立仓库;创建带注释的 `v0.1.0` tag。
## 2. 使编译器仓库自持
- [ ] 将 CLI 作为 package `bin` 入口,接受由宿主写出的 `RoadCompilerInput` JSON并继续输出唯一的 `NATIVE_ROAD_COMPILE_DONE` JSON 标记。
- [ ] 补全 `package.json`、锁文件和 `ol` 依赖workbench vendor 文件从自身安装目录提供。
- [ ] 迁入 `docs/native-road-package-v1.md`、两个 baseline 和它们的测试驱动,新增独立 README。
- [ ] 在不含宿主文件的临时 clone 内执行依赖安装、单元测试、两区域 parity 与 workbench 启动测试。
- [ ] grep 验证没有 `area-config``config/areas`、宿主项目名或宿主相对路径引用。
## 3. 宿主改为外部 CLI 消费
- [ ] 将 compiler 以精确 git tag `v0.1.0` 安装到宿主并记录 lockfile禁止范围版本、`latest``*` 和裸分支。
- [ ]`toRoadCompilerInput()` 序列化到每个区域的 pipeline staging 目录,传给 compiler CLI。
- [ ]`build-area.js` 的 blender 前置阶段调用 CLI。沿用管线的 `spawnSync` / `runCommand` 失败语义启动错误、exit status、signal和直通日志缓冲或 tee stdout 后,解析恰好一个完成标记并验证其 `areaId``output` 与当前区域匹配。
- [ ] 将 host workbench 的 fresh compile 回调切换到同一 CLI不改变 fresh-process 行为。
- [ ] 把所有 host 对 `packages/road-compiler/**` 的直接 import 替换成已安装包的公开 API 或 CLI两个区域 parity 均通过后删除 in-host 副本。
- [ ] 将宿主 pipeline spec 改为指向编译器仓库所拥有的契约文档。
## 4. 验证与发布门
新仓库(干净 clone
```bash
# 方案 Asubtree split
git subtree split --prefix=packages/road-compiler -b road-compiler-export
# 在新目录初始化并拉入该分支
# 方案 Bfilter-repo更彻底推荐
git clone --no-local . /tmp/road-compiler-split
cd /tmp/road-compiler-split
git filter-repo --path packages/road-compiler/ --path-rename packages/road-compiler/:
npm ci
npm test
npm run test:road-parity
npm run road:workbench -- --config test/fixtures/fengshu-er-road.input.json --no-compile
```
⚠️ 注意Phase 1 的搬迁若用了 `git mv`,历史可 `--follow` 追溯;
若用了删除+新建,`filter-repo` 只能追到 Phase 1。
**Phase 1 搬迁时必须用 `git mv`** —— 这条已在 Phase 1 implement Step 3 隐含,
此处显式记录依赖。
- [ ] 拆出后验证:`git log --follow src/compile/native-road.js | tail -20`
能看到 08-13 native-road-compiler 的提交
## Step 3 — 新仓库自持化
- [ ] `package.json` 补全依赖(含 K4 的 OpenLayers
- [ ] `npm install && npm test` 在新仓库独立通过
- [ ] 基线 JSON 迁入 `test/baseline/`
- [ ] 契约文档迁入 `docs/`
- [ ] README契约摘要 + 与宿主项目关系说明
## Step 4 — 宿主改造
- [ ] `package.json` 依赖指向新仓库(开发期 `file:`
- [ ] `build-area.js` 改子进程调用 + 解析 `NATIVE_ROAD_COMPILE_DONE`
- [ ] `area-config.js``toRoadCompilerInput()` 输出改为 CLI 参数或 input JSON 文件
- [ ] 删除 `packages/road-compiler/`(或按过渡期决策保留)
- [ ] `.trellis/spec/pipeline/` 加契约指针
## Step 5 — 验证
宿主两个有效区域的逐字节 parity
```bash
# 宿主端到端
npm run build:area -- --config config/areas/fengshu-er-road.json
# 三区域 parity
for a in fengshu-er-road hanyang-block nantaizi-lake-innovation-valley; do
node scripts/road-parity.js --config config/areas/$a.json \
--compare .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json || echo "FAIL $a"
done
# 反向依赖为 0在新仓库内跑
grep -rn "osm2streets-qgis-workflow\|area-config" . --exclude-dir=node_modules --exclude-dir=.git \
&& echo "VIOLATION"
# 锁版本检查
node -e 'const d=require("./package.json").dependencies;console.log(d)' | grep -i road
node scripts/road-parity.js --config config/areas/fengshu-er-road.json \
--compare .trellis/tasks/08-25-road-compiler-extraction/baseline/fengshu-er-road.json
node scripts/road-parity.js --config config/areas/nantaizi-lake-innovation-valley.json \
--compare .trellis/tasks/08-25-road-compiler-extraction/baseline/nantaizi-lake-innovation-valley.json
```
- [ ] 在一台未 clone 宿主的环境(或 `/tmp` 全新 clone验证 AC2.2
## Review Gate
AC2.1AC2.7 全绿。特别是 AC2.2(脱离宿主可自测)—— 这是"独立可维护"的实质。
- [ ] 为 CLI 标记成功、缺失、重复、非法 JSON、areaId 不匹配和 output 不匹配补测试。
- [ ] 跑 package 单测、宿主 native-road / workbench / traffic signals / turn-lane-arrows 测试,以及两个 parity 检查。
- [ ] 使用 `trellis-check` 做最终跨层检查Blender 完整构建需以提权方式运行,原因是 macOS Metal 初始化不能在沙箱内启动。
## Rollback
- 宿主依赖改回 `file:packages/road-compiler`(若过渡期保留了该目录)
- `git revert` 宿主侧改造 commit新仓库留着不影响宿主
- 外部仓库和已发布 tag 保留不动;在宿主 revert 消费改造即可回到 P1 边界。
- 若按决定临时保留 in-host 副本,可将依赖回指该副本以恢复;该退路不取代 host parity 验证。

View File

@@ -1,80 +1,62 @@
# Phase 2拆仓库
父任务:`.trellis/tasks/08-25-road-compiler-extraction/`
技术设计:父任务 `design.md` §1.3消费方式、§2模块清单
## Goal
`packages/road-compiler/`保留 git 历史的独立仓库,
宿主改为锁版本依赖 + 子进程调用消费。
当前 `packages/road-compiler/`保留 git 历史、可脱离宿主独立测试和运行的仓库;宿主改为消费一个锁定版本的编译器 CLI。这样道路编译器可以独立演进宿主依然通过版本化文件契约稳定构建场景。
## Confirmed Facts
- P1 已完成:编译器实现和 workbench 的所有权在 `packages/road-compiler/`;宿主负责将区域配置映射为 `RoadCompilerInput`
- `git log --follow packages/road-compiler/src/compile/native-road.js` 已能追溯到 2026-08-13 的 native-road compiler 历史。
- 契约为 `native-road-package/v1`,完成标记为 `NATIVE_ROAD_COMPILE_DONE <json>``comparisonDir` 是可选输入,继续保留 `comparison.json`
- 当前宿主远端为内部 Git 服务 `https://git.app.que01.top/que01/osmWorkflow.git`P2 独立编译器仓库定为私有 `https://git.app.que01.top/que01/road-compiler.git`
- 可维护范围只有 `fengshu-er-road``nantaizi-lake-innovation-valley``hanyang-block` 是废案,不参与基线、测试、验收或迁移语料。
## Requirements
### R2.1 保留 git 历史 🔴
### R2.1 保留 Git 历史
-`git subtree split``git filter-repo` 出,**不得**用 `cp` + `git init`
- 理由(父任务 R3.21695 行几何逻辑的 blame 是踩坑记录,
丢了以后没人敢改 `compileGeometry` 里任何一行
- 验证:新仓库内 `git log --follow src/compile/native-road.js` 能看到
08-13 native-road-compiler 以来的完整历史
-`git subtree split``git filter-repo` 出,不得以复制目录再 `git init` 替代。
- 新仓库中 `git log --follow src/compile/native-road.js` 必须能追溯到 P1 之前的 native-road compiler 提交。
### R2.2 子进程为主契约
### R2.2 新仓库可独立运行
- 宿主 `build-area.js` 改为 `execFileSync` 调编译器 CLI
解析 `NATIVE_ROAD_COMPILE_DONE` stdout 标记(父任务 design §1.3
- in-process `require` 可保留为可选优化路径,但不得是唯一路径
- 与既有 QGIS / GDAL / Blender 调用方式一致
(见 `.trellis/spec/pipeline/external-tools.md`
- 新仓库拥有 compiler、CLI、check、workbench、其测试 fixture、两个 parity baseline、契约文档和 README。
- 新仓库 own `ol` 及其 workbench 所需的运行依赖workbench 不得从宿主 `node_modules` 提供浏览器资源。
- 新仓库不得引用宿主的 `area-config``config/areas``scripts/` 或本仓库绝对路径
### R2.3 锁版本依赖
### R2.3 宿主的消费边界
- 开发期:`file:` 或 workspace 依赖
- 稳定后git tag / 私有 npm宿主 `package.json` **锁具体版本,不用 `latest`**
- 契约版本 `native-road-package/v1` 与包版本分开演进:
包可以发 patch契约版本只在破坏性变更时升
- `scripts/lib/area-config.js` 继续是唯一的区域配置归一化与 `RoadCompilerInput` 映射位置。
- `scripts/build-area.js` 用已安装编译器的 CLI 子进程编译,继承 stdout/stderr检查启动错误、退出状态和 signal并解析且校验唯一的 `NATIVE_ROAD_COMPILE_DONE` 标记。
- 宿主只依赖 CLI、写入的文件和版本化输入 JSON不再以相对路径 import 编译器内部模块。保留的宿主 signal 文件 I/O 适配层改为只调用公开包 API。
- `road-workbench` 保持每次编译均启动新进程的语义,但改为调用已安装 CLI。
### R2.4 K4workbench 依赖自持
### R2.4 版本与迁移
- `road-workbench` 的 OpenLayers 从新仓库自己的 `node_modules` 提供
- import map 路径相应调整
### R2.5 基线迁移
- Phase 0 的三区域基线 JSON 搬进新仓库当测试语料(父任务 R4 第三条)
- 新仓库 CI/test 能独立跑 parity无需宿主在场
- **同时**宿主保留一份,用于验证升级编译器版本后产物未变
### R2.6 契约文档迁移
- `docs/native-road-package-v1.md` 搬进新仓库
- 宿主 `.trellis/spec/pipeline/` 留指针,说明契约由编译器仓库拥有
- 宿主依赖必须锁定一个具体编译器版本,禁止 `latest``*`;包版本与 `native-road-package/v1` 的契约版本独立演进。
- 初始分发使用带注释的 git tag `v0.1.0`;宿主锁定该 tag。完整 parity 验证后移除 `packages/road-compiler/`,不保留 in-host 副本作为正常消费路径。
- 宿主保留两份 baseline用来验证今后升级编译器版本后没有产物漂移。
## Acceptance Criteria
- [ ] AC2.1 仓库 `git log --follow` 能追到搬迁前的历史
- [ ] AC2.2 新仓库 `npm test` 在**未 clone 宿主**的机器上通过
- [ ] AC2.3 宿主从锁版本依赖构建,三区域 parity 对基线全绿
- [ ] AC2.4 `build-area.js` 走子进程路径,`NATIVE_ROAD_COMPILE_DONE` 被正确解析
- [ ] AC2.5 宿主 `package.json` 依赖为具体版本,非 `latest` / 非 `*`
- [ ] AC2.6 编译器仓库对宿主反向依赖数为 0父任务 AC8grep 验证)
- [ ] AC2.7 `npm run road:workbench` 在新仓库内独立可跑
## 依赖与顺序
- **前置**Phase 1 完成且 AC1.1AC1.8 全绿
- **阻塞**Phase 3、Phase 4
## 待决策(进入本阶段时定)
| 项 | 选项 |
|---|---|
| 仓库名 | `road-compiler` / `native-road-compiler` / `osm-road-compiler` |
| 托管 | GitHub 私有 / 公开 / 内部 git |
| 分发 | git tag 依赖 / 私有 npm registry |
| 宿主过渡期 | 是否保留 `packages/road-compiler/` 一段时间做双跑对照 |
- [ ] AC2.1 独立仓库 `git log --follow src/compile/native-road.js` 可见 P1 前的 compiler 历史
- [ ] AC2.2 在未 clone 宿主的干净目录中,新仓库 `npm ci`(或等价锁文件安装)和 `npm test`通过
- [ ] AC2.3 新仓库的两区域 parity`fengshu-er-road``nantaizi-lake-innovation-valley`)均与迁入 baseline 完全一致。
- [ ] AC2.4 宿主用锁版本依赖运行 CLI`build-area.js` 成功解析一个有效的 `NATIVE_ROAD_COMPILE_DONE` 标记,缺失、重复或非法标记会失败。
- [ ] AC2.5 宿主的两个区域 parity 均对保留 baseline 全绿。
- [ ] AC2.6 宿主 `package.json` 的 compiler 依赖是具体、可复现版本,不是 `latest``*` 或裸分支。
- [ ] AC2.7 编译器仓库对宿主反向依赖为 0且其 workbench 可独立启动。
## Out of Scope
- layer manifestPhase 3
- drawtonomy 扩展Phase 4
- 任何编译器内部逻辑改动 —— 本阶段代码内容不变,只换位置与消费方式
- 编译器几何、规则或输出内容改动。
- 图层 manifest 和道路/建筑渲染分离Phase 3
- drawtonomy 扩展Phase 4
- `hanyang-block` 修复、迁移或作为验收样本。
## Release Decision
独立仓库为私有 `https://git.app.que01.top/que01/road-compiler.git`。首发以带注释 tag `v0.1.0` 发布,宿主依赖精确锁定该 tag两个区域 parity 均通过后删除 `packages/road-compiler/`。该决定授权 P2 在该内部远端创建和推送仓库,但不授权发布到 npm 或其他托管平台。

View File

@@ -3,7 +3,7 @@
"name": "rc-p2-repo-split",
"title": "Phase 2拆仓库",
"description": "保留 git 历史拆出独立仓库,宿主以锁版本依赖消费,子进程为主契约",
"status": "planning",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,

11
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.3.0",
"dependencies": {
"@inquirer/prompts": "^8.5.2",
"@osm-asset/road-compiler": "git+https://git.app.que01.top/que01/road-compiler.git#v0.1.2",
"ol": "^10.10.0",
"osm2streets-js-node": "0.1.4"
}
@@ -341,6 +342,16 @@
}
}
},
"node_modules/@osm-asset/road-compiler": {
"version": "0.1.0",
"resolved": "git+https://git.app.que01.top/que01/road-compiler.git#90400d967f9be6ad6420b8b29e5d7d652eee3493",
"dependencies": {
"ol": "10.10.0"
},
"bin": {
"road-compiler": "bin/road-compiler.js"
}
},
"node_modules/@petamoriken/float16": {
"version": "3.9.3",
"resolved": "http://172.16.1.86:4873/@petamoriken/float16/-/float16-3.9.3.tgz",

View File

@@ -3,9 +3,6 @@
"version": "0.3.0",
"private": true,
"type": "commonjs",
"workspaces": [
"packages/road-compiler"
],
"scripts": {
"build": "node scripts/interactive-build.js",
"build:area": "node scripts/build-area.js",
@@ -21,6 +18,7 @@
"serve:v2x-preview": "node scripts/v2x-preview-server.js",
"test:road-workbench": "node scripts/test-road-workbench.js",
"test:native-road": "node scripts/test-native-road.js",
"test:road-compiler-cli": "node scripts/test-road-compiler-cli.js",
"test:road-parity": "node scripts/test-road-parity.js",
"test:gaode-junction-reference": "node scripts/test-gaode-junction-reference.js",
"test:preflight": "node scripts/test-area-preflight.js",
@@ -39,6 +37,7 @@
},
"dependencies": {
"@inquirer/prompts": "^8.5.2",
"@osm-asset/road-compiler": "git+https://git.app.que01.top/que01/road-compiler.git#v0.1.2",
"ol": "^10.10.0",
"osm2streets-js-node": "0.1.4"
}

View File

@@ -1,10 +0,0 @@
{
"name": "@osm-asset/road-compiler",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
"scripts": {
"test": "node test/index.js"
}
}

View File

@@ -1,27 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
function checkOutput({ areaId, outDir }) {
if (typeof areaId !== "string" || !areaId) throw new Error("RoadCompilerCheckInput.areaId must be a non-empty string");
if (typeof outDir !== "string" || !outDir) throw new Error("RoadCompilerCheckInput.outDir must be a non-empty string");
const compiledPath = path.join(outDir, "compiled.json");
if (!fs.existsSync(compiledPath)) throw new Error(`Native road output is missing: ${compiledPath}`);
const compiled = readJson(compiledPath);
const connectors = readJson(path.join(outDir, "layers", "connectors.geojson"));
const published = new Set(connectors.features.map((feature) => feature.properties.movement_id));
const failures = [];
for (const movement of compiled.movements || []) {
if (movement.geometryPublished && !published.has(movement.id)) failures.push(`Published movement has no connector: ${movement.id}`);
if (!movement.geometryPublished && published.has(movement.id)) failures.push(`Non-published movement has a connector: ${movement.id}`);
if (!movement.geometryStatus) failures.push(`Movement has no geometry status: ${movement.id}`);
}
const errors = (compiled.diagnostics || []).filter((item) => item.severity === "error");
const warnings = (compiled.diagnostics || []).filter((item) => item.severity === "warning");
return { schema: "native-road-check/v1", areaId, ok: failures.length === 0 && errors.length === 0, movementCount: (compiled.movements || []).length, connectorCount: connectors.features.length, errors: errors.map((item) => ({ id: item.id, rule: item.rule, message: item.message })), warningCount: warnings.length, failures };
}
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
module.exports = { checkOutput };

View File

@@ -1,141 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./native-road");
const { loadOrGenerate, runtime } = require("../native-traffic-signals");
function compileInput(input) {
validateInput(input);
const area = {
id: input.areaId,
input: input.osmFile,
nativeRoad: input.options,
outputs: {
nativeRoadOverrides: input.overridesFile,
nativeTrafficSignals: input.trafficSignalsFile,
nativeRoadDir: input.outDir,
pipelineDir: input.stagingDir,
geojsonDir: input.comparisonDir || null,
},
};
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
// Editing the source OSM retires the ids some overrides point at. Those
// entries can no longer match anything, so drop them with a diagnostic rather
// than aborting the whole compile — otherwise every OSM edit blocks the
// pipeline until the file is hand-pruned, one error message at a time.
const validated = validateOverrides(overrides, model, { skipStaleTargets: true });
for (const item of validated.stale) console.warn(`[warning] 忽略失效的 override目标已不存在${item.id} -> ${item.target}`);
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines, junctionTemplates: area.nativeRoad.junctionTemplates });
compiled.diagnostics.push(...validated.stale.map((item) => ({
id: `diagnostic:stale-override:${item.id}`,
severity: "warning",
subjectId: item.id,
sourceIds: [],
rule: "stale-override-target",
message: `该设置指向的 ${item.kind} 目标 ${item.target} 已不存在OSM 改动后 id 失效),本次编译已忽略。可在工作台重新设置,或从 native-road-overrides.json 中删除。`,
geometry: null,
})));
const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface);
const signalRuntime = runtime(signalDocument);
// Persist validation normalization, including one-time legacy heading migration.
writeJsonAtomic(area.outputs.nativeTrafficSignals, signalDocument);
const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-"));
try {
const result = {
schema: "native-road-compiled/v1",
areaId: area.id,
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides, trafficSignals: area.outputs.nativeTrafficSignals },
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
movements: compiled.movements,
trafficSignals: { assemblies: "traffic-signal-assemblies.json", runtime: "traffic-signals.json", count: signalRuntime.signals.length },
diagnostics: compiled.diagnostics,
layers: { roadSurface: "layers/road_surface.geojson", edgeLines: "layers/edge_lines.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", centerLines: "layers/center_lines.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.geojson", connectors: "layers/connectors.geojson" },
};
const comparison = compareOsm2Streets(area, result.model, compiled);
writeJsonAtomic(path.join(staging, "compiled.json"), result);
writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics });
writeJsonAtomic(path.join(staging, "comparison.json"), comparison);
writeJsonAtomic(path.join(staging, "traffic-signal-assemblies.json"), signalDocument.assemblies);
writeJsonAtomic(path.join(staging, "traffic-signals.json"), signalRuntime);
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
writeJsonAtomic(path.join(staging, "layers", "edge_lines.geojson"), compiled.edgeLines);
writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface);
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines);
writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators);
writeJsonAtomic(path.join(staging, "layers", "center_lines.geojson"), compiled.centerLines);
writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows);
writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows);
writeJsonAtomic(path.join(staging, "layers", "crosswalks.geojson"), compiled.crosswalks);
writeJsonAtomic(path.join(staging, "layers", "vehicle_stop_lines.geojson"), compiled.vehicleStopLines);
writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors);
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
fs.renameSync(staging, area.outputs.nativeRoadDir);
return { area, result, comparison };
} catch (error) {
fs.rmSync(staging, { recursive: true, force: true });
throw error;
}
}
function compareOsm2Streets(area, model, compiled) {
const source = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, "road_surface.geojson") : null;
let featureCount = null;
if (source && fs.existsSync(source)) {
const collection = JSON.parse(fs.readFileSync(source, "utf8"));
featureCount = Array.isArray(collection.features) ? collection.features.length : null;
}
const diagnosticsBySeverity = {};
const diagnosticsByRule = {};
for (const item of compiled.diagnostics) {
diagnosticsBySeverity[item.severity] = (diagnosticsBySeverity[item.severity] || 0) + 1;
diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1;
}
const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end");
const junctions = compiled.intersectionSurface.features;
const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback");
return {
schema: "native-road-comparison/v2",
nativeRoadCount: model.roads.length,
nativeRoadSurfaceFeatures: compiled.roadSurface.features.length,
nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length,
nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length,
nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length,
nativeFallbackJunctions: fallbackJunctions.length,
nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0),
nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length,
nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length,
nativeCenterLineFeatures: compiled.centerLines.features.length,
nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
nativeTurnArrowFeatures: compiled.turnArrows.features.length,
nativeCrosswalkFeatures: compiled.crosswalks.features.length,
nativeVehicleStopLineFeatures: compiled.vehicleStopLines.features.length,
nativeConnectorFeatures: compiled.connectors.features.length,
nativeMovementCount: compiled.movements.length,
nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length,
nativeConnectionCount: model.connections.length,
unconnectedInteriorRoadEnds: dangling.length,
unconnectedEndsWithManualCandidates: dangling.filter((item) => item.manualCandidates?.length).length,
diagnosticsBySeverity,
diagnosticsByRule,
osm2streetsRoadSurfaceFeatures: featureCount,
osm2streetsAvailable: featureCount !== null,
note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review.",
};
}
function validateInput(input) {
if (!input || typeof input !== "object") throw new Error("RoadCompilerInput must be an object");
for (const key of ["areaId", "osmFile", "outDir", "stagingDir", "overridesFile", "trafficSignalsFile"]) {
if (typeof input[key] !== "string" || input[key].trim() === "") throw new Error(`RoadCompilerInput.${key} must be a non-empty string`);
}
if (!input.options || typeof input.options !== "object") throw new Error("RoadCompilerInput.options must be an object");
if (typeof input.options.edgeLines !== "boolean") throw new Error("RoadCompilerInput.options.edgeLines must be a boolean");
if (!input.options.junctionTemplates || typeof input.options.junctionTemplates !== "object") throw new Error("RoadCompilerInput.options.junctionTemplates must be an object");
}
module.exports = { compileInput, validateInput };

View File

@@ -1,474 +0,0 @@
"use strict";
const fs = require("fs");
const { convertGeoJson, boundsOf } = require("../reference/gaode");
const metricsCache = new WeakMap();
const CORNER_FILLET_SEGMENTS = 12;
// Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band
// lines up with the straight strips it joins.
const SIDEWALK_WIDTH_METERS = 2;
// The straight strips are trimmed against the cluster boundary using the road
// centerline, so they stop a little beyond the carriageway end. Run the corner
// past that end and let the two overlap rather than chase an exact seam.
const SIDEWALK_CORNER_OVERRUN_METERS = 6;
function buildComplexJunctionGeometry(model, cluster, helpers) {
const nodeIds = new Set(cluster.nodeIds.map(String));
const nodes = cluster.nodeIds.map((id) => helpers.junctionPlans.get(String(id))?.node).filter(Boolean);
if (nodes.length < 2) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-nodes", "复合路口至少需要两个有效节点。", null)] };
const center = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]);
const approaches = [];
const carriageways = [];
for (const [nodeId, plan] of helpers.junctionPlans) {
if (!nodeIds.has(String(nodeId))) continue;
for (const approach of plan.approaches) {
const endpoint = approach.line.at(-1);
if (nodes.some((node) => node !== plan.node && helpers.distanceMeters(endpoint, node) < 4)) continue;
const heading = helpers.headingAtEndpoint(approach.line);
const length = helpers.lineLengthMeters(approach.line);
carriageways.push({ nodeId, approach, plan, heading, length });
if (approaches.some((item) => Math.abs(normalizeHeading(item.heading - heading)) < 20)) continue;
approaches.push({ nodeId, approach, plan, heading, length });
}
}
if (approaches.length < 3) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-approaches", "复合路口无法识别足够的外部进口。", center)] };
const { calibration, coreRadius } = complexJunctionMetrics(cluster);
const sorted = [...approaches].sort((a, b) => a.heading - b.heading);
const arms = sorted.map((representative) => ({
representative,
heading: averageHeading(carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20).map((candidate) => candidate.heading)),
members: carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20),
}));
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
const boundaryParts = [];
const crosswalks = [];
const stopLines = [];
const islands = [];
const armCrosswalkRadius = coreRadius * .68;
for (const item of carriageways) {
const outer = pointOnCarriagewayRadius(item, center, outerRadius, helpers);
const inner = pointOnCarriagewayRadius(item, center, coreRadius * .7, helpers);
const outerHalf = item.approach.widthMeters / 2;
const innerHalf = outerHalf;
boundaryParts.push({ item, outer, inner, outerHalf, innerHalf });
const incomingRoad = item.approach.roadIds.map((roadId) => model.roads.find((road) => road.id === roadId)).find((road) => String(road?.sourceNodeIds.at(-1)) === String(item.nodeId));
if (incomingRoad) {
// Keep the stop bar just outside the road crosswalk. The previous fixed
// core-radius offset placed it nearly ten metres beyond the crossing.
const stopCenter = pointOnCarriagewayRadius(item, center, armCrosswalkRadius + 3, helpers);
const ring = [
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, -.24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, .24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, .24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
];
stopLines.push({ type: "Feature", properties: { native_id: `complex-stop-line:${cluster.id}:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-stop-line", road_id: incomingRoad.id, node_id: item.nodeId, direction: item.heading, provenance: "native-road-complex-junction-stop-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
// The four support lines provide a common corner frame, but each long
// crossing remains clipped to its OSM-derived road envelope. Corner islands
// fill the remaining frame gaps; crossings must never do that job.
for (const arm of arms) {
const envelope = armEnvelopeAtRadius(arm, armCrosswalkRadius, center, helpers);
if (!envelope) continue;
arm.crosswalkFrame = {
center: envelope.center,
groupDepth: 3.4,
supportHeading: normalizeHeading(arm.heading + 90),
envelopeWidthMeters: envelope.widthMeters,
};
}
const frameCorners = arms.map((arm, index) => {
const next = arms[(index + 1) % arms.length];
const delta = positiveHeadingDelta(arm.heading, next.heading);
if (delta < 45 || delta > 135 || !arm.crosswalkFrame || !next.crosswalkFrame) return null;
return supportLineIntersection(arm.crosswalkFrame, next.crosswalkFrame, center);
});
for (let index = 0; index < arms.length; index += 1) {
const arm = arms[index];
if (!arm.crosswalkFrame) continue;
const item = arm.representative;
const roadEdgeInset = .35;
const usableSpan = Math.max(.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2);
const endpoints = [
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, -usableSpan / 2),
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, usableSpan / 2),
];
const groupDepth = arm.crosswalkFrame.groupDepth;
const stripeWidth = .42;
const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / .82) + 1);
const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0;
arm.crosswalkFrame.center = midpoint(...endpoints);
arm.crosswalkFrame.endpoints = endpoints;
arm.crosswalkFrame.spanMeters = usableSpan;
arm.crosswalkFrame.roadEdgeInsetMeters = roadEdgeInset;
for (let stripe = 0; stripe < stripeCount; stripe += 1) {
const along = stripeWidth / 2 + stripe * stripeSpacing;
const centerPoint = helpers.offsetCoordinate(endpoints[0], bearing(...endpoints), along);
const ring = [
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
];
crosswalks.push({ type: "Feature", properties: { native_id: `complex-crosswalk:${cluster.id}:${item.nodeId}:${item.approach.segmentId}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-crosswalk", crossing_node_id: item.nodeId, road_id: item.approach.roadIds[0], direction: arm.heading, radial_distance_m: armCrosswalkRadius, span_m: usableSpan, road_envelope_span_m: arm.crosswalkFrame.envelopeWidthMeters, road_edge_inset_m: roadEdgeInset, group_depth_m: groupDepth, stripe_width_m: stripeWidth, stripe_spacing_m: stripeSpacing, frame_center: arm.crosswalkFrame.center, frame_support_heading: arm.crosswalkFrame.supportHeading, provenance: "native-road-complex-junction-crosswalk/v6-road-clipped" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
// The four arm groups are the sides of one pedestrian frame. Each diagonal
// group is anchored at the intersection of its adjacent side support lines,
// so all eight groups stay one composition when the OSM arms are skewed.
for (let index = 0; index < arms.length; index += 1) {
const first = arms[index];
const second = arms[(index + 1) % arms.length];
const delta = positiveHeadingDelta(first.heading, second.heading);
if (delta < 45 || delta > 135) continue;
if (!first.crosswalkFrame || !second.crosswalkFrame) continue;
const bisector = normalizeHeading(first.heading + delta / 2);
const frameCorner = frameCorners[index];
if (!frameCorner) continue;
const cornerStripeSpacing = .62;
const cornerStripeWidth = .4;
const cornerGroupHalfDepth = (5 * cornerStripeSpacing + cornerStripeWidth) / 2;
const endpointForCorner = (arm) => [...arm.crosswalkFrame.endpoints].sort((a, b) => helpers.distanceMeters(a, frameCorner) - helpers.distanceMeters(b, frameCorner))[0];
const outerEdgeAtCorner = (arm) => {
const endpoint = endpointForCorner(arm);
return [arm.heading, arm.heading + 180]
.map((heading) => helpers.offsetCoordinate(endpoint, heading, arm.crosswalkFrame.groupDepth / 2))
.sort((a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector))[0];
};
const islandBaseGap = .05;
const islandApexOffset = 1.5;
const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) => helpers.offsetCoordinate(point, bisector, islandBaseGap));
const islandApex = helpers.offsetCoordinate(frameCorner, bisector, islandApexOffset);
const islandCrossingClearance = .2;
const cornerCrossingOffset = islandApexOffset + islandCrossingClearance + cornerGroupHalfDepth;
const islandInnerRadius = Math.min(...islandBase.map((point) => directionalProjectionMeters(center, point, bisector)));
const islandOuterRadius = directionalProjectionMeters(center, islandApex, bisector);
const cornerCrossingCenter = helpers.offsetCoordinate(frameCorner, bisector, cornerCrossingOffset);
const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], .24);
islands.push({ type: "Feature", properties: { native_id: `complex-corner-island:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-island", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, frame_corner: frameCorner, base_points: islandBase, apex_point: islandApex, inner_radius_m: islandInnerRadius, outer_radius_m: islandOuterRadius, base_width_m: helpers.distanceMeters(...islandBase), crossing_clearance_m: islandCrossingClearance, corner_rounding_ratio: .24, provenance: "native-road-complex-junction-corner/v7-road-gap-fill" }, geometry: { type: "Polygon", coordinates: [islandRing] } });
let cornerCrossingHalfSpan = .4;
for (let stripe = 0; stripe < 6; stripe += 1) {
const stripeOffset = (stripe - 2.5) * cornerStripeSpacing;
const stripeCenter = helpers.offsetCoordinate(cornerCrossingCenter, bisector, stripeOffset);
const stripeRadius = directionalProjectionMeters(center, stripeCenter, bisector);
const curbPair = limitedCornerPair(first, second, stripeRadius, bisector, center, 6.5, helpers);
if (!curbPair) continue;
const halfSpan = Math.max(.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2);
cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan);
const stripePair = [
helpers.offsetCoordinate(stripeCenter, bisector - 90, halfSpan),
helpers.offsetCoordinate(stripeCenter, bisector + 90, halfSpan),
];
const ring = [
helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2),
helpers.offsetCoordinate(stripePair[1], bisector, -cornerStripeWidth / 2),
helpers.offsetCoordinate(stripePair[1], bisector, cornerStripeWidth / 2),
helpers.offsetCoordinate(stripePair[0], bisector, cornerStripeWidth / 2),
helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2),
];
crosswalks.push({ type: "Feature", properties: { native_id: `complex-corner-crosswalk:${cluster.id}:${index + 1}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-corner-crosswalk", corner_index: index + 1, direction: bisector, radial_distance_m: stripeRadius, frame_corner: frameCorner, from_heading: first.heading, to_heading: second.heading, provenance: "native-road-complex-junction-corner-crosswalk/v3" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
// Each carriageway ends in its own rectangle, so adjacent arms meet at a
// sharp notch instead of a curb. A real corner is one tangent-continuous
// sweep between the two outermost road edges, so fit a fixed-radius fillet
// into the wedge those edges form and fill the sector behind it.
const cornerFills = [];
const sidewalkCorners = [];
const cornerDiagnostics = [];
const cornerRadius = Math.max(4, Math.min(25, Number(cluster.cornerRadiusMeters) || 12));
for (let index = 0; index < arms.length; index += 1) {
const first = arms[index];
const second = arms[(index + 1) % arms.length];
const delta = positiveHeadingDelta(first.heading, second.heading);
if (delta < 45 || delta > 135) continue;
const bisector = normalizeHeading(first.heading + delta / 2);
const edges = [first, second].map((arm) => cornerEdgeAt(arm, coreRadius + 6, bisector, center, helpers));
if (!edges.every(Boolean)) continue;
const apex = rayIntersection(edges[0], edges[1], center);
const apexReach = apex ? directionalProjectionMeters(center, apex, bisector) : null;
// The wedge apex has to sit ahead of the core and inside the arm handoff;
// outside that band the two edges are near parallel and any fillet fitted
// to them would sweep across the carriageways instead of the corner.
if (apexReach === null || apexReach < 1 || apexReach > outerRadius) {
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-corner-fillet-fallback", "该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。", center));
continue;
}
// Tangent distance for a circle of `cornerRadius` inscribed in a wedge of
// opening `delta`, clamped so the tangent points stay on the built arms.
const tangentDistance = Math.min(cornerRadius / Math.tan(delta * Math.PI / 360), Math.max(2, outerRadius - apexReach));
const tangents = edges.map((edge) => helpers.offsetCoordinate(apex, edge.heading, tangentDistance));
const curve = quadraticCurve(tangents[0], apex, tangents[1], CORNER_FILLET_SEGMENTS);
const ring = [...curve, center, curve[0]];
if (!ring.every((point) => point.every(Number.isFinite))) continue;
cornerFills.push({ type: "Feature", properties: { native_id: `complex-corner-fillet:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-fillet", complex_part: "corner-fillet", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, corner_radius_m: cornerRadius, tangent_distance_m: Math.round(tangentDistance * 100) / 100, apex_reach_m: Math.round(apexReach * 100) / 100, provenance: "native-road-complex-junction-corner-fillet/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
// The straight pedestrian strips are trimmed at the cluster boundary, so
// two arms that both carry a footway still meet as two loose ends across
// an empty wedge. Bridge them along the curb the fillet already defines.
// The corner faces clockwise from `first` and counter-clockwise from
// `second`, so each arm must carry the footway on that facing side.
if (!armCarriesSidewalk(first, model, true) || !armCarriesSidewalk(second, model, false)) continue;
const curb = [
...edgeRunToRadius(apex, edges[0], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers).reverse(),
...curve.slice(1, -1),
...edgeRunToRadius(apex, edges[1], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers),
];
const outerEdge = offsetPolylineAwayFromCenter(curb, center, SIDEWALK_WIDTH_METERS, helpers);
const sidewalkRing = [...curb, ...outerEdge.slice().reverse(), curb[0]];
if (!sidewalkRing.every((point) => point.every(Number.isFinite)) || ringSelfIntersects(sidewalkRing)) {
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-sidewalk-corner-fallback", "该夹角的人行道转角几何自交或无效,已跳过,两侧步行带保持断开。", center));
continue;
}
sidewalkCorners.push({ type: "Feature", properties: { native_id: `complex-sidewalk-corner:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-sidewalk-corner", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, width_m: SIDEWALK_WIDTH_METERS, overrun_m: SIDEWALK_CORNER_OVERRUN_METERS, provenance: "native-road-complex-junction-sidewalk-corner/v1" }, geometry: { type: "Polygon", coordinates: [sidewalkRing] } });
}
const corePoints = boundaryParts.flatMap(({ item, inner, innerHalf }) => [helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf)]).sort((first, second) => angleAround(center, first) - angleAround(center, second));
const coreRing = roundedPolygonRing(corePoints, .16);
const features = [{ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:core`, cluster_id: cluster.id, kind: "complex-core", complex_part: "core", center, radius_m: coreRadius, configured_radius_m: cluster.coreRadiusMeters, approach_count: approaches.length, carriageway_count: carriageways.length, approach_headings: sorted.map((item) => Math.round(item.heading * 10) / 10), corner_rounding_ratio: .16, provenance: "native-road-complex-junction/v6-rounded-core" }, geometry: { type: "Polygon", coordinates: [coreRing] } }];
for (const { item, outer, inner, outerHalf, innerHalf } of boundaryParts) {
const ring = [helpers.offsetCoordinate(outer, item.heading + 90, outerHalf), helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf), helpers.offsetCoordinate(outer, item.heading - 90, outerHalf), helpers.offsetCoordinate(outer, item.heading + 90, outerHalf)];
features.push({ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:carriageway:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-approach", complex_part: "carriageway", heading_deg: item.heading, lane_count: item.approach.roadIds.reduce((sum, roadId) => sum + (model.roads.find((road) => road.id === roadId)?.laneCount || 0), 0), width_m: item.approach.widthMeters, provenance: "native-road-complex-junction/v5" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
// Corner fills come last so they overlay the rectangular carriageway ends
// they are smoothing; they never replace an OSM-derived road surface.
features.push(...cornerFills);
// `coreRadiusMeters` is only consulted when there is no reference geometry.
// Under calibration the radius comes from the reference span, so a configured
// value that silently does nothing has to be reported, not swallowed.
const configuredRadiusIgnored = calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > .5;
const configurationDiagnostics = configuredRadiusIgnored
? [helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-configured-radius-ignored", `已按参考几何校准核心半径为 ${Math.round(coreRadius * 10) / 10} 米,配置的 coreRadiusMeters=${cluster.coreRadiusMeters} 在有参考文件时不生效。`, center)]
: [];
return { features, islands: [...islands, ...sidewalkCorners], crosswalks, stopLines, center, approaches, diagnostics: [...cornerDiagnostics, ...configurationDiagnostics, helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], calibration ? "complex-junction-reference-calibrated" : "complex-junction-generated", calibration ? `已使用参考几何校准参数后,由 OSM/native 重新生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。` : `已独立生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。`, center)] };
}
function readReferenceCalibration(cluster) {
if (!cluster.referenceFile || !fs.existsSync(cluster.referenceFile)) return null;
try {
const converted = convertGeoJson(JSON.parse(fs.readFileSync(cluster.referenceFile, "utf8")));
const bounds = boundsOf({ features: converted.features.filter((feature) => [1, 2, 3, 4].includes(Number(feature.properties?.type))) });
const lonScale = 111320 * Math.cos(((bounds.minLat + bounds.maxLat) / 2) * Math.PI / 180);
return { longSpanMeters: (bounds.maxLon - bounds.minLon) * lonScale, shortSpanMeters: (bounds.maxLat - bounds.minLat) * 111320 };
} catch (_) {
return null;
}
}
function complexJunctionMetrics(cluster) {
if (metricsCache.has(cluster)) return metricsCache.get(cluster);
const calibration = readReferenceCalibration(cluster);
// Without a reference geometry `coreRadiusMeters` is the only size input the
// template has, so honour it literally within the schema's own 12..80 range.
// It used to be scaled by .52 and clamped to 17, which silently capped every
// unreferenced junction at a core far smaller than its own approach envelope
// — a 24 m dual-carriageway arm was being fitted into a 16.6 m core no matter
// what the config asked for. The calibrated branch is unchanged.
const coreRadius = calibration
? Math.max(12, Math.min(24, calibration.shortSpanMeters * .14))
: Math.max(12, Math.min(80, Number(cluster.coreRadiusMeters) || 28));
const metrics = { calibration, coreRadius, approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18) };
metricsCache.set(cluster, metrics);
return metrics;
}
function normalizeHeading(value) { return ((value + 180) % 360 + 360) % 360 - 180; }
// `arm.heading` points outward from the junction, so the corner clockwise from
// it sits at heading+90 and the one counter-clockwise at heading-90. A road's
// own sidewalk flags are relative to its digitisation direction, so flip them
// whenever the arm runs against that direction.
function armCarriesSidewalk(arm, model, cornerIsClockwise) {
return arm.members.some((member) => member.approach.roadIds
.map((roadId) => model.roads.find((road) => road.id === roadId))
.filter(Boolean)
.some((road) => {
const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId);
const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft;
const onCounterClockwiseSide = outwardIsForward ? road.sidewalkLeft : road.sidewalkRight;
return Boolean(cornerIsClockwise ? onClockwiseSide : onCounterClockwiseSide);
}));
}
// Walk outward along a wedge edge from its tangent point until the curb reaches
// `targetRadius`, so the corner band overlaps the straight strip it joins.
function edgeRunToRadius(apex, edge, tangentDistance, targetRadius, center, helpers) {
const points = [];
for (let extra = 0; extra <= 40; extra += 2) {
const point = helpers.offsetCoordinate(apex, edge.heading, tangentDistance + extra);
points.push(point);
if (helpers.distanceMeters(point, center) >= targetRadius) break;
}
return points;
}
// Offset each vertex along the polyline normal that increases distance from the
// junction centre. The curb is star-shaped around that centre, so "farther from
// the centre" is a reliable stand-in for "on the pedestrian side".
function offsetPolylineAwayFromCenter(points, center, meters, helpers) {
return points.map((point, index) => {
const previous = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
const tangent = previous === next ? 0 : bearing(previous, next);
return [tangent + 90, tangent - 90]
.map((heading) => helpers.offsetCoordinate(point, heading, meters))
.sort((first, second) => helpers.distanceMeters(second, center) - helpers.distanceMeters(first, center))[0];
});
}
function ringSelfIntersects(ring) {
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
const straddles = (p1, p2, p3, p4) => {
const d1 = cross(p3, p4, p1); const d2 = cross(p3, p4, p2);
const d3 = cross(p1, p2, p3); const d4 = cross(p1, p2, p4);
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
};
for (let first = 0; first < ring.length - 1; first += 1) {
for (let second = first + 2; second < ring.length - 1; second += 1) {
if (first === 0 && second === ring.length - 2) continue;
if (straddles(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
}
}
return false;
}
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
function signedLateralMeters(origin, point, heading) {
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
const north = (point[1] - origin[1]) * 111320;
const radians = (heading + 90) * Math.PI / 180;
return east * Math.sin(radians) + north * Math.cos(radians);
}
function bearing(first, second) {
const east = (second[0] - first[0]) * Math.cos(first[1] * Math.PI / 180);
const north = second[1] - first[1];
return Math.atan2(east, north) * 180 / Math.PI;
}
function midpoint(first, second) { return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]; }
function averageHeading(headings) {
const vector = headings.reduce((sum, heading) => {
const radians = heading * Math.PI / 180;
return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)];
}, [0, 0]);
return Math.atan2(vector[0], vector[1]) * 180 / Math.PI;
}
function positiveHeadingDelta(first, second) { return ((second - first) % 360 + 360) % 360; }
function pointOnCarriagewayRadius(item, center, radius, helpers) {
const start = item.approach.line[0];
const startRadius = directionalProjectionMeters(center, start, item.heading);
return helpers.pointAlongLine(item.approach.line, Math.max(0, Math.min(item.length, radius - startRadius)));
}
function armEnvelopeAtRadius(arm, radius, center, helpers) {
if (!arm.members.length) return null;
const centers = arm.members.map((member) => pointOnCarriagewayRadius(member, center, radius, helpers));
const reference = centers[0];
let minimum = Infinity;
let maximum = -Infinity;
centers.forEach((point, index) => {
const lateral = signedLateralMeters(reference, point, arm.heading);
const halfWidth = arm.members[index].approach.widthMeters / 2;
minimum = Math.min(minimum, lateral - halfWidth);
maximum = Math.max(maximum, lateral + halfWidth);
});
if (!Number.isFinite(minimum) || maximum - minimum < 1) return null;
return { center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2), widthMeters: maximum - minimum };
}
function supportLineIntersection(first, second, origin) {
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
const firstPoint = toLocal(first.center);
const secondPoint = toLocal(second.center);
const direction = (heading) => {
const radians = heading * Math.PI / 180;
return [Math.sin(radians), Math.cos(radians)];
};
const firstDirection = direction(first.supportHeading);
const secondDirection = direction(second.supportHeading);
const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
if (Math.abs(denominator) < 1e-6) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
const intersection = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
return [origin[0] + intersection[0] / lonScale, origin[1] + intersection[1] / 111320];
}
function limitedCornerPair(first, second, radius, bisector, center, maxWidth, helpers) {
const pair = [cornerEdgeAtRadius(first, radius, bisector, center, helpers), cornerEdgeAtRadius(second, radius, bisector, center, helpers)];
if (!pair.every(Boolean)) return null;
const width = helpers.distanceMeters(pair[0], pair[1]);
const middle = midpoint(pair[0], pair[1]);
const halfWidth = Math.max(.4, Math.min(width, maxWidth) / 2);
const acrossHeading = width > .1 ? bearing(pair[0], pair[1]) : bisector + 90;
return [helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth), helpers.offsetCoordinate(middle, acrossHeading, halfWidth)];
}
function cornerEdgeAtRadius(arm, radius, bisector, center, helpers) {
return cornerEdgeAt(arm, radius, bisector, center, helpers)?.point || null;
}
function cornerEdgeAt(arm, radius, bisector, center, helpers) {
const candidates = arm.members.flatMap((member) => {
const point = pointOnCarriagewayRadius(member, center, radius, helpers);
const halfWidth = member.approach.widthMeters / 2;
return [90, -90].map((side) => ({ point: helpers.offsetCoordinate(point, member.heading + side, halfWidth), heading: member.heading }));
});
return candidates.sort((first, second) => directionalProjectionMeters(center, second.point, bisector) - directionalProjectionMeters(center, first.point, bisector))[0] || null;
}
function rayIntersection(first, second, origin) {
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
const direction = (heading) => {
const radians = heading * Math.PI / 180;
return [Math.sin(radians), Math.cos(radians)];
};
const firstPoint = toLocal(first.point);
const secondPoint = toLocal(second.point);
const firstDirection = direction(first.heading);
const secondDirection = direction(second.heading);
const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
if (Math.abs(denominator) < 1e-4) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
const local = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
if (!local.every(Number.isFinite)) return null;
return [origin[0] + local[0] / lonScale, origin[1] + local[1] / 111320];
}
function quadraticCurve(start, control, end, segments) {
const result = [];
for (let index = 0; index <= segments; index += 1) {
const t = index / segments;
const u = 1 - t;
result.push([u * u * start[0] + 2 * u * t * control[0] + t * t * end[0], u * u * start[1] + 2 * u * t * control[1] + t * t * end[1]]);
}
return result;
}
function directionalProjectionMeters(origin, point, heading) {
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
const north = (point[1] - origin[1]) * 111320;
const radians = heading * Math.PI / 180;
return east * Math.sin(radians) + north * Math.cos(radians);
}
function smoothClosedRing(vertices) {
// Curb-edge candidates can arrive in opposite winding orders when an OSM
// carriageway bends slightly. Sort this local corner only around its own
// centroid before rounding, avoiding a self-crossing safety island while
// keeping the global junction boundary fully OSM-driven.
const centroid = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]);
const ordered = [...vertices].sort((first, second) => Math.atan2(first[1] - centroid[1], first[0] - centroid[0]) - Math.atan2(second[1] - centroid[1], second[0] - centroid[0]));
const points = ordered.flatMap((point, index) => {
const next = ordered[(index + 1) % ordered.length];
return [interpolateCoordinate(point, next, .18), interpolateCoordinate(point, next, .82)];
});
return [...points, points[0]];
}
function roundedPolygonRing(vertices, ratio) {
const points = vertices.flatMap((point, index) => {
const previous = vertices[(index - 1 + vertices.length) % vertices.length];
const next = vertices[(index + 1) % vertices.length];
return [interpolateCoordinate(previous, point, 1 - ratio), interpolateCoordinate(point, next, ratio)];
});
return [...points, points[0]];
}
function interpolateCoordinate(first, second, ratio) { return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio]; }
module.exports = { buildComplexJunctionGeometry, complexJunctionMetrics };

File diff suppressed because it is too large Load Diff

View File

@@ -1,502 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const { laneCenterline } = require("../geometry/lane-geometry");
const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "..", "..", "assets", "lane-icons", "manifest.json");
const LANE_WIDTH_METERS = 3.2;
const PLACEMENT_DISTANCE_METERS = 9;
const SPATIAL_MATCH_MAX_DISTANCE_METERS = 18;
const SPATIAL_MATCH_MIN_ALIGNMENT = Math.cos(Math.PI / 6);
// Existing osm2streets lane arrows are approximately 1.4 m across. Keep the
// 25-unit upstream icon at the same on-road scale rather than at screen scale.
const SVG_METERS_PER_UNIT = 0.10;
function loadManifest(file = ASSET_MANIFEST) {
const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
if (!Array.isArray(manifest.assets)) throw new Error("turn-lane asset manifest has no assets array");
return manifest;
}
function supportedAssets(manifest = loadManifest()) {
return new Map(manifest.assets
.filter((asset) => asset.supported === true && asset.tested === true)
.map((asset) => [asset.id, asset]));
}
function buildCustomTurnLaneArrows(osm, options = {}) {
const enabled = options.enabled === true;
const diagnostics = [];
if (!enabled) return { features: [], diagnostics: [{ reason: "disabled" }] };
const assets = supportedAssets(options.manifest);
const endpointRoadCounts = roadCountsByNode(osm);
const networkIntersectionNodes = new Set((options.network?.intersections || [])
.flatMap(([, intersection]) => intersection.osm_ids || []).map(Number));
const features = [];
const ways = [...osm.ways.values()].sort((a, b) => a.id - b.id);
for (const way of ways) {
for (const direction of ["forward", "backward"]) {
const tag = way.tags[`turn:lanes:${direction}`];
if (!tag) continue;
const laneCount = directionalLaneCount(way, direction);
if (!laneCount) {
diagnostics.push(skip(way, direction, "missing_lane_count"));
continue;
}
const endpoint = endpointGeometry(osm, way, direction, endpointRoadCounts, networkIntersectionNodes);
if (!endpoint) {
diagnostics.push(skip(way, direction, "indeterminate_intersection_endpoint"));
continue;
}
const maneuvers = String(tag).split("|").map((value) => normalizeManeuver(value));
for (let laneIndex = 0; laneIndex < maneuvers.length; laneIndex += 1) {
const maneuver = maneuvers[laneIndex];
const asset = assets.get(maneuver);
if (!asset) {
diagnostics.push(skip(way, direction, "unsupported_or_untested_maneuver", { lane_index: laneIndex, maneuver }));
continue;
}
if (laneIndex >= laneCount) {
diagnostics.push(skip(way, direction, "lane_index_exceeds_lane_count", { lane_index: laneIndex, maneuver }));
continue;
}
const resolvedPlacement = lanePlacement(way, direction, laneIndex, endpoint, options.lanePolygons, options.crosswalkStripes, options.stopLines);
if (resolvedPlacement?.blocked) {
diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver }));
continue;
}
const placement = resolvedPlacement || fallbackLanePlacement(endpoint, direction, laneIndex, options.crosswalkStripes, options.stopLines);
if (!placement) {
diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver }));
continue;
}
const parts = templateFor(asset.id, options.manifest);
for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
features.push(makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, parts[partIndex], placement.center, placement));
}
}
}
}
return { features, diagnostics };
}
function normalizeManeuver(value) {
const parts = String(value || "").split(";").map((part) => part.trim()).filter(Boolean).sort();
const supported = new Map([
["through", "through"], ["left", "left"], ["right", "right"],
["left;through", "through;left"], ["right;through", "through;right"],
["left;right;through", "through;left;right"],
]);
return supported.get(parts.join(";")) || parts.join(";");
}
function directionalLaneCount(way, direction) {
const specific = Number(way.tags[`lanes:${direction}`]);
if (Number.isInteger(specific) && specific > 0) return specific;
const total = Number(way.tags.lanes);
if (Number.isInteger(total) && total > 0 && total % 2 === 0 && !isOneway(way)) return total / 2;
if (Number.isInteger(total) && total > 0 && isOneway(way)) return total;
return null;
}
function roadCountsByNode(osm) {
const out = new Map();
for (const way of osm.ways.values()) {
if (!way.tags.highway || way.tags.highway === "service") continue;
for (const ref of new Set(way.refs)) out.set(ref, (out.get(ref) || 0) + 1);
}
return out;
}
function endpointGeometry(osm, way, direction, roadCounts, networkIntersectionNodes) {
const forward = direction === "forward";
const endpointIndex = forward ? way.refs.length - 1 : 0;
const neighborIndex = forward ? endpointIndex - 1 : 1;
const node = osm.nodes.get(way.refs[endpointIndex]);
const neighbor = osm.nodes.get(way.refs[neighborIndex]);
if (!node || !neighbor) return null;
const networkSaysIntersection = networkIntersectionNodes && networkIntersectionNodes.size > 0 && networkIntersectionNodes.has(node.id);
if (!networkSaysIntersection && (roadCounts.get(node.id) || 0) < 3) return null;
const meters = metersForLat(node.lat);
// For both directions, point from the adjacent road node to the endpoint.
// At a forward endpoint this is the OSM-way direction; at a backward
// endpoint it is the reverse OSM-way direction, i.e. the actual travel
// direction used by turn:lanes:backward.
const raw = [node.lon - neighbor.lon, node.lat - neighbor.lat];
const axis = normalizeMetersVector(raw, meters);
if (!axis) return null;
return { node, axis, right: [axis[1], -axis[0]], meters };
}
function laneCenter(endpoint, direction, laneIndex, meters) {
const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS;
// The local axis always follows travel, so moving back from either endpoint
// places the marking on its approach lane before the intersection.
return addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -PLACEMENT_DISTANCE_METERS, endpoint.right, lateral, meters);
}
function fallbackLanePlacement(endpoint, direction, laneIndex, crosswalkStripes, stopLines) {
const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS;
for (const distance of [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]) {
const center = addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -distance, endpoint.right, lateral, endpoint.meters);
if (!nearIntersectionMarking(center, endpoint.axis, crosswalkStripes, stopLines, endpoint.meters)) {
return { center, axis: endpoint.axis, right: endpoint.right, meters: endpoint.meters, placementDistance: distance, placementSource: "osm_way_fallback" };
}
}
return null;
}
function lanePlacement(way, direction, laneIndex, endpoint, lanePolygons, crosswalkStripes, stopLines) {
if (!Array.isArray(lanePolygons)) return null;
const expectedDirection = direction === "forward" ? "Fwd" : "Back";
const directionalCandidates = lanePolygons.filter((feature) =>
feature.properties?.type === "Driving" &&
feature.properties.direction === expectedDirection
);
let candidates = directionalCandidates.filter((feature) =>
(feature.properties.osm_way_ids || []).map(Number).includes(way.id)
);
let placementSource = "driving_lane_centerline";
let spatialAnchors = null;
if (!candidates.length) {
const ranked = directionalCandidates
.map((feature) => ({ feature, anchor: spatialLaneAnchor(feature, endpoint) }))
.filter(({ anchor }) => anchor)
.filter(({ anchor }) => anchor.alignment >= SPATIAL_MATCH_MIN_ALIGNMENT && anchor.distance <= SPATIAL_MATCH_MAX_DISTANCE_METERS)
.sort((a, b) => a.anchor.distance - b.anchor.distance || a.anchor.lateral - b.anchor.lateral || Number(a.feature.properties.index) - Number(b.feature.properties.index));
if (ranked.length) {
// JOSM may split a tagged OSM way into temporary negative IDs. Those IDs
// are absent from osm2streets' rendered polygons, so associate the full
// physical approach by endpoint proximity and road-axis alignment.
candidates = ranked.map(({ feature }) => feature);
placementSource = "spatial_driving_lane_centerline";
spatialAnchors = new Map(ranked.map(({ feature, anchor }) => [feature, anchor]));
}
}
candidates.sort((a, b) => {
const lateralA = spatialAnchors?.get(a)?.lateral;
const lateralB = spatialAnchors?.get(b)?.lateral;
if (Number.isFinite(lateralA) && Number.isFinite(lateralB) && lateralA !== lateralB) return lateralA - lateralB;
return Number(a.properties.index) - Number(b.properties.index);
});
const lane = candidates[laneIndex];
const spatialAnchor = spatialAnchors?.get(lane);
if (spatialAnchor) {
const sampled = placementDistances().map((distance) => ({
center: sampleCenterlineAwayFromEndpoint(spatialAnchor, distance, endpoint.meters),
distance,
})).find(({ center }) => center && !nearIntersectionMarking(center, spatialAnchor.axis, crosswalkStripes, stopLines, endpoint.meters));
if (!sampled) return { blocked: true };
return { center: sampled.center, axis: spatialAnchor.axis, right: [spatialAnchor.axis[1], -spatialAnchor.axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource };
}
const centerline = laneCenterline(lane);
if (!centerline) return null;
const startsAtEndpoint = direction === "backward";
const ordered = startsAtEndpoint ? centerline : [...centerline].reverse();
const sampled = [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]
.map((distance) => ({ center: samplePolyline(ordered, distance, endpoint.meters), distance }))
.find(({ center }) => center && !nearIntersectionMarking(center, axisForLane(ordered, endpoint.meters), crosswalkStripes, stopLines, endpoint.meters));
if (!sampled) return { blocked: true };
const axis = axisForLane(ordered, endpoint.meters);
if (!axis) return null;
return { center: sampled.center, axis, right: [axis[1], -axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource };
}
function spatialLaneAnchor(lane, endpoint) {
const centerline = laneCenterline(lane);
if (!centerline) return null;
let best = null;
for (let index = 0; index < centerline.length - 1; index += 1) {
const start = centerline[index];
const end = centerline[index + 1];
const point = closestPointOnSegment([endpoint.node.lon, endpoint.node.lat], start, end, endpoint.meters);
const distance = Math.hypot((point[0] - endpoint.node.lon) * endpoint.meters.lon, (point[1] - endpoint.node.lat) * endpoint.meters.lat);
const tangent = normalizeMetersVector(subtractPoint(end, start), endpoint.meters);
if (!tangent || (best && distance >= best.distance)) continue;
const dot = tangent[0] * endpoint.axis[0] + tangent[1] * endpoint.axis[1];
const axis = dot >= 0 ? tangent : [-tangent[0], -tangent[1]];
const offset = subtractPoint(point, [endpoint.node.lon, endpoint.node.lat]);
best = {
point,
distance,
axis,
alignment: Math.abs(dot),
lateral: offset[0] * endpoint.right[0] * endpoint.meters.lon + offset[1] * endpoint.right[1] * endpoint.meters.lat,
centerline,
segmentIndex: index,
};
}
return best;
}
function placementDistances() {
return [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42];
}
function sampleCenterlineAwayFromEndpoint(anchor, distanceMeters, meters) {
const { centerline, segmentIndex } = anchor;
const start = centerline[segmentIndex];
const end = centerline[segmentIndex + 1];
const tangent = normalizeMetersVector(subtractPoint(end, start), meters);
if (!tangent) return null;
// Walk away from the junction along the rendered centerline. This preserves
// curved or split lane geometry instead of approximating it with a tangent.
const towardEnd = tangent[0] * anchor.axis[0] + tangent[1] * anchor.axis[1] < 0;
const points = [anchor.point];
if (towardEnd) {
for (let index = segmentIndex + 1; index < centerline.length; index += 1) points.push(centerline[index]);
} else {
for (let index = segmentIndex; index >= 0; index -= 1) points.push(centerline[index]);
}
return samplePolyline(points, distanceMeters, meters);
}
function closestPointOnSegment(point, start, end, meters) {
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const lengthSquared = dx * dx + dy * dy;
const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0;
return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])];
}
function axisForLane(ordered, meters) {
return normalizeMetersVector(subtractPoint(ordered[0], ordered[1]), meters);
}
function nearIntersectionMarking(center, axis, stripes, stopLines, meters) {
if (!axis) return true;
const right = [axis[1], -axis[0]];
const samples = [];
for (const forward of [-0.2, 0.5, 1.2, 1.9, 2.2]) {
for (const lateral of [-1.6, -0.8, 0, 0.8, 1.6]) {
samples.push(addMeters(center, axis, forward, right, lateral, meters));
}
}
return [...(stripes || []), ...(stopLines || [])].some((feature) => samples.some((point) => nearFeature(point, feature, meters)));
}
function nearFeature(point, feature, meters) {
const ring = feature.geometry?.type === "Polygon" ? feature.geometry.coordinates?.[0] : null;
if (!ring?.length) return false;
const xs = ring.map((coordinate) => coordinate[0]);
const ys = ring.map((coordinate) => coordinate[1]);
const clearance = 0.7;
const dx = Math.max((Math.min(...xs) - point[0]) * meters.lon, 0, (point[0] - Math.max(...xs)) * meters.lon);
const dy = Math.max((Math.min(...ys) - point[1]) * meters.lat, 0, (point[1] - Math.max(...ys)) * meters.lat);
return Math.hypot(dx, dy) < clearance;
}
function subtractPoint([lon, lat], [otherLon, otherLat]) {
return [lon - otherLon, lat - otherLat];
}
function samplePolyline(points, distanceMeters, meters) {
let remaining = distanceMeters;
for (let index = 0; index < points.length - 1; index += 1) {
const start = points[index];
const end = points[index + 1];
const vector = normalizeMetersVector(subtractPoint(end, start), meters);
const length = Math.hypot((end[0] - start[0]) * meters.lon, (end[1] - start[1]) * meters.lat);
if (!vector || !length) continue;
if (remaining <= length) return addMeters(start, vector, remaining, [0, 0], 0, meters);
remaining -= length;
}
return null;
}
function makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, template, center, endpoint) {
const ring = template.map(([rightMeters, forwardMeters]) => addMeters(center, endpoint.axis, forwardMeters, endpoint.right, rightMeters, endpoint.meters));
return {
type: "Feature",
properties: {
type: "lane arrow",
source: "osm_turn_lanes",
osm_way_id: way.id,
direction,
lane_index: laneIndex,
maneuver,
source_asset: asset.id,
source_asset_path: asset.source,
arrow_part: partIndex,
// SVG strokes and fills are expanded separately for GeoJSON validity.
// This stable key lets the QGIS normalizer restore one rendered arrow.
custom_arrow_id: `${way.id}:${direction}:${laneIndex}:${maneuver}`,
placement_distance_meters: endpoint.placementDistance ?? PLACEMENT_DISTANCE_METERS,
placement_source: endpoint.placementSource ?? "osm_way_fallback",
},
geometry: { type: "Polygon", coordinates: [ring] },
};
}
function skip(way, direction, reason, extra = {}) {
return { source: "osm_turn_lanes", osm_way_id: way.id, direction, reason, ...extra };
}
function isOneway(way) {
return ["yes", "true", "1"].includes(String(way.tags.oneway || "").toLowerCase());
}
function metersForLat(lat) {
return { lon: 111320 * Math.cos((lat * Math.PI) / 180), lat: 110540 };
}
function normalizeMetersVector([dxLon, dyLat], meters) {
const x = dxLon * meters.lon;
const y = dyLat * meters.lat;
const length = Math.hypot(x, y);
return length ? [x / length, y / length] : null;
}
function addMeters(center, axis, axisDistance, right, rightDistance, meters) {
return [
center[0] + (axis[0] * axisDistance + right[0] * rightDistance) / meters.lon,
center[1] + (axis[1] * axisDistance + right[1] * rightDistance) / meters.lat,
];
}
function arrowRingsAt(maneuver, center, axis, manifest = loadManifest()) {
const normalized = normalizeManeuver(maneuver);
if (!supportedAssets(manifest).has(normalized) || !Array.isArray(center) || !Array.isArray(axis)) return [];
const meters = metersForLat(center[1]);
const length = Math.hypot(axis[0], axis[1]);
if (!Number.isFinite(length) || length < 0.001) return [];
const forward = [axis[0] / length, axis[1] / length];
const right = [forward[1], -forward[0]];
return templateFor(normalized, manifest).map((template) => template.map(([rightMeters, forwardMeters]) =>
addMeters(center, forward, forwardMeters, right, rightMeters, meters)));
}
function templateFor(assetId, manifest = loadManifest()) {
const asset = supportedAssets(manifest).get(assetId);
if (!asset) throw new Error(`Unsupported or untested turn-lane asset: ${assetId}`);
return angularTemplate(assetId);
}
function angularTemplate(assetId) {
const shaftWidth = 0.30;
const shaftHalf = shaftWidth / 2;
const straightBase = 1.18;
const straightTip = 1.92;
const rectangle = (minX, minY, maxX, maxY) => [
[minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY], [minX, minY],
];
const throughHead = () => [[0, straightTip], [-0.42, straightBase], [-shaftHalf, straightBase], [-shaftHalf, 0], [shaftHalf, 0], [shaftHalf, straightBase], [0.42, straightBase], [0, straightTip]];
const diagonalShaft = (side) => {
const start = [0, 0.56];
const end = [side * 0.72, 0.96];
const length = Math.hypot(end[0] - start[0], end[1] - start[1]);
const normal = [-(end[1] - start[1]) / length * shaftHalf, (end[0] - start[0]) / length * shaftHalf];
return [[start[0] + normal[0], start[1] + normal[1]], [end[0] + normal[0], end[1] + normal[1]], [end[0] - normal[0], end[1] - normal[1]], [start[0] - normal[0], start[1] - normal[1]], [start[0] + normal[0], start[1] + normal[1]]];
};
const diagonalHead = (side) => {
const base = [side * 0.60, 0.89];
const tip = [side * 1.22, 1.24];
const length = Math.hypot(tip[0] - base[0], tip[1] - base[1]);
const normal = [-(tip[1] - base[1]) / length * 0.36, (tip[0] - base[0]) / length * 0.36];
return [tip, [base[0] + normal[0], base[1] + normal[1]], [base[0] - normal[0], base[1] - normal[1]], tip];
};
const turnStem = (side) => {
const cutMidpoint = 0.73;
const cutRise = side * 0.084;
return [
[-shaftHalf, 0], [shaftHalf, 0],
[shaftHalf, cutMidpoint + cutRise], [-shaftHalf, cutMidpoint - cutRise],
[-shaftHalf, 0],
];
};
if (assetId === "through") return [throughHead()];
if (assetId === "right") return [turnStem(1), diagonalShaft(1), diagonalHead(1)];
if (assetId === "left") return [turnStem(-1), diagonalShaft(-1), diagonalHead(-1)];
if (assetId === "through;right") return [throughHead(), diagonalShaft(1), diagonalHead(1)];
if (assetId === "through;left") return [throughHead(), diagonalShaft(-1), diagonalHead(-1)];
if (assetId === "through;left;right") return [throughHead(), diagonalShaft(-1), diagonalHead(-1), diagonalShaft(1), diagonalHead(1)];
throw new Error(`No angular turn-lane template: ${assetId}`);
}
function sourceSvgTemplateFor(asset, assetId) {
const source = fs.readFileSync(path.resolve(__dirname, "..", "..", "assets", "lane-icons", asset.source), "utf8");
const mirrorX = asset.mirror_x === true;
const anchorX = Number(asset.anchor_x);
if (!Number.isFinite(anchorX)) throw new Error(`turn-lane asset has invalid anchor_x: ${assetId}`);
const shapes = [];
for (const match of source.matchAll(/<line\b([^>]*)\/>|<path\b([^>]*)\/>/g)) {
const attrs = parseSvgAttrs(match[1] || match[2]);
const strokeWidth = Number(attrs["stroke-width"] || 0);
if (match[1]) {
shapes.push(strokePolygon([[Number(attrs.x1), Number(attrs.y1)], [Number(attrs.x2), Number(attrs.y2)]], strokeWidth));
} else {
const points = parseSvgPath(attrs.d || "");
if (attrs.fill !== "none") shapes.push(points);
if (strokeWidth > 0) shapes.push(strokePolygon(points, strokeWidth));
}
}
return shapes.filter((ring) => ring.length >= 4).map((ring) => ring.map(([x, y]) => [
(mirrorX ? anchorX - x : x - anchorX) * SVG_METERS_PER_UNIT,
(23 - y) * SVG_METERS_PER_UNIT,
]));
}
function parseSvgAttrs(text) {
const attrs = {};
for (const match of text.matchAll(/([\w:-]+)=(['"])(.*?)\2/g)) attrs[match[1]] = match[3];
return attrs;
}
function parseSvgPath(value) {
const tokens = value.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) || [];
let index = 0;
let command = "";
let point = [0, 0];
let start = null;
const points = [];
const number = () => Number(tokens[index++]);
const lineTo = (x, y) => { point = [x, y]; points.push(point); };
while (index < tokens.length) {
if (/^[a-zA-Z]$/.test(tokens[index])) command = tokens[index++];
const relative = command === command.toLowerCase();
const op = command.toUpperCase();
if (op === "Z") { if (start) points.push(start); command = ""; continue; }
if (op === "M" || op === "L") {
const x = number(); const y = number();
const next = relative ? [point[0] + x, point[1] + y] : [x, y];
if (op === "M" && !start) { start = next; point = next; points.push(point); command = relative ? "l" : "L"; } else lineTo(...next);
continue;
}
if (op === "H") { lineTo(relative ? point[0] + number() : number(), point[1]); continue; }
if (op === "V") { lineTo(point[0], relative ? point[1] + number() : number()); continue; }
if (op === "C") {
const values = [number(), number(), number(), number(), number(), number()];
const controls = relative ? values.map((n, i) => n + point[i % 2]) : values;
const origin = point;
for (let step = 1; step <= 8; step += 1) {
const t = step / 8; const u = 1 - t;
lineTo(u ** 3 * origin[0] + 3 * u ** 2 * t * controls[0] + 3 * u * t ** 2 * controls[2] + t ** 3 * controls[4], u ** 3 * origin[1] + 3 * u ** 2 * t * controls[1] + 3 * u * t ** 2 * controls[3] + t ** 3 * controls[5]);
}
continue;
}
if (op === "A") { number(); number(); number(); number(); number(); const x = number(); const y = number(); lineTo(relative ? point[0] + x : x, relative ? point[1] + y : y); continue; }
throw new Error(`Unsupported SVG path command: ${command}`);
}
return points;
}
function strokePolygon(points, width) {
if (points.length < 2) return [];
const half = width / 2;
const left = []; const right = [];
for (let index = 0; index < points.length; index += 1) {
const prev = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
const dx = next[0] - prev[0]; const dy = next[1] - prev[1]; const length = Math.hypot(dx, dy) || 1;
const nx = -dy / length * half; const ny = dx / length * half;
left.push([points[index][0] + nx, points[index][1] + ny]);
right.unshift([points[index][0] - nx, points[index][1] - ny]);
}
return [...left, ...right, left[0]];
}
module.exports = { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };

View File

@@ -1,161 +0,0 @@
"use strict";
const EARTH_RADIUS_METERS = 6371008.8;
function laneCenterline(lane) {
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
if (!Array.isArray(ring) || ring.length < 5 || !sameCoordinate(ring[0], ring.at(-1))) return null;
const vertices = ring.slice(0, -1);
if (!vertices.every(validCoordinate)) return null;
const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null;
const centerline = vertices.slice(0, half).map((point, index) => [
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
]);
return polylineLength(centerline) > 0.01 ? centerline : null;
}
function orientPolyline(polyline, reference) {
if (!polyline?.length || !reference?.length) return null;
const forward = projectedDistanceAlong(reference, polyline.at(-1)) - projectedDistanceAlong(reference, polyline[0]);
if (Math.abs(forward) < 0.01) return null;
return forward > 0 ? polyline.map(copyCoordinate) : [...polyline].reverse().map(copyCoordinate);
}
function stitchPolylines(polylines, maxGapMeters) {
if (!polylines.length) return null;
const result = [];
for (const polyline of polylines) {
if (!polyline?.length) return null;
if (result.length && haversineMeters(result.at(-1), polyline[0]) > maxGapMeters) return null;
appendCoordinates(result, polyline);
}
return result;
}
function projectedDistanceAlong(polyline, point) {
let traversed = 0;
let best = { distance: Infinity, along: 0, lateral: 0 };
for (let index = 1; index < polyline.length; index += 1) {
const start = polyline[index - 1];
const end = polyline[index];
const meters = metersAt((start[1] + end[1]) / 2);
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const length = Math.hypot(dx, dy);
if (length < 0.001) continue;
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
const offsetX = px - dx * ratio;
const offsetY = py - dy * ratio;
const distance = Math.hypot(offsetX, offsetY);
if (distance < best.distance) {
const rightX = dy / length;
const rightY = -dx / length;
best = {
distance,
along: traversed + length * ratio,
lateral: offsetX * rightX + offsetY * rightY,
};
}
traversed += length;
}
return best.along;
}
function lateralOffsetFrom(polyline, point) {
let best = null;
for (let index = 1; index < polyline.length; index += 1) {
const start = polyline[index - 1];
const end = polyline[index];
const meters = metersAt((start[1] + end[1]) / 2);
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const length = Math.hypot(dx, dy);
if (length < 0.001) continue;
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
const offsetX = px - dx * ratio;
const offsetY = py - dy * ratio;
const distance = Math.hypot(offsetX, offsetY);
if (!best || distance < best.distance) {
best = { distance, lateral: offsetX * dy / length - offsetY * dx / length };
}
}
return best;
}
function polylineMidpoint(polyline) {
const target = polylineLength(polyline) / 2;
let traversed = 0;
for (let index = 1; index < polyline.length; index += 1) {
const length = haversineMeters(polyline[index - 1], polyline[index]);
if (traversed + length >= target) {
const ratio = length ? (target - traversed) / length : 0;
return [
polyline[index - 1][0] + (polyline[index][0] - polyline[index - 1][0]) * ratio,
polyline[index - 1][1] + (polyline[index][1] - polyline[index - 1][1]) * ratio,
];
}
traversed += length;
}
return polyline.length ? copyCoordinate(polyline.at(-1)) : null;
}
function polylineLength(polyline) {
let total = 0;
for (let index = 1; index < (polyline?.length || 0); index += 1) {
total += haversineMeters(polyline[index - 1], polyline[index]);
}
return total;
}
function haversineMeters(a, b) {
const lat1 = degreesToRadians(a[1]);
const lat2 = degreesToRadians(b[1]);
const dLat = degreesToRadians(b[1] - a[1]);
const dLon = degreesToRadians(b[0] - a[0]);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(h)));
}
function appendCoordinates(target, coordinates) {
for (const coordinate of coordinates) {
if (!sameCoordinate(target.at(-1), coordinate)) target.push(copyCoordinate(coordinate));
}
}
function validCoordinate(value) {
return Array.isArray(value) && value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1]);
}
function sameCoordinate(a, b) {
return Boolean(a && b && a[0] === b[0] && a[1] === b[1]);
}
function copyCoordinate(coordinate) {
return [coordinate[0], coordinate[1]];
}
function metersAt(latitude) {
return { lon: 111320 * Math.cos(degreesToRadians(latitude)), lat: 111320 };
}
function degreesToRadians(value) {
return value * Math.PI / 180;
}
module.exports = {
appendCoordinates,
haversineMeters,
laneCenterline,
lateralOffsetFrom,
orientPolyline,
polylineLength,
polylineMidpoint,
projectedDistanceAlong,
stitchPolylines,
};

View File

@@ -1,14 +0,0 @@
"use strict";
module.exports = {
laneGeometry: require("./geometry/lane-geometry"),
gaodeReference: require("./reference/gaode"),
turnLaneArrows: require("./compile/turn-lane-arrows"),
complexJunction: require("./compile/complex-junction"),
osm: require("./osm"),
trafficSignals: require("./traffic-signals"),
nativeTrafficSignals: require("./native-traffic-signals"),
nativeRoad: require("./compile/native-road"),
compiler: require("./compile/compiler"),
check: require("./check"),
};

View File

@@ -1,49 +0,0 @@
"use strict";
const fs = require("fs");
const { parseOsm } = require("./osm");
const {
buildTrafficSignalFeatures,
buildTrafficSignalsFromFeatures,
validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences,
} = require("./traffic-signals");
const SCHEMA = "native-traffic-signals/v1";
function loadOrGenerate(file, osmText, stopLines, intersections) {
if (fs.existsSync(file)) {
const document = JSON.parse(fs.readFileSync(file, "utf8"));
try {
return validateDocument(document, osmText);
} catch (error) {
// OSM edits can invalidate the stable identities in a document that was
// itself generated from OSM. User-authored documents must remain strict.
if (document?.provenance === "generated:osm-controls" && isStaleSourceReferenceError(error)) {
return generate(osmText, stopLines, intersections);
}
throw error;
}
}
return generate(osmText, stopLines, intersections);
}
function isStaleSourceReferenceError(error) {
return error instanceof Error && /^traffic signal feature \d+: (approach_id .* is not present on OSM control|control_id .* is not present in the current OSM)/.test(error.message);
}
function generate(osmText, stopLines, intersections) {
const controls = parseOsm(osmText).trafficSignalControls;
return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) };
}
function validateDocument(value, osmText) {
if (value?.schema !== SCHEMA) throw new Error(`Expected ${SCHEMA} signal document`);
const assemblies = validateTrafficSignalFeatures(value.assemblies);
if (osmText) validateTrafficSignalSourceReferences(assemblies, parseOsm(osmText).trafficSignalControls);
return { schema: SCHEMA, provenance: value.provenance || "native", assemblies };
}
function runtime(document) { return buildTrafficSignalsFromFeatures(document.assemblies); }
module.exports = { SCHEMA, generate, loadOrGenerate, validateDocument, runtime };

View File

@@ -1,102 +0,0 @@
"use strict";
function parseOsm(xml) {
const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/);
const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {};
const candidateBounds = {
minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat),
maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat),
};
const bounds = Object.values(candidateBounds).every(Number.isFinite) ? candidateBounds : null;
const nodes = new Map();
const trafficSignalControls = [];
const nodePattern = /<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g;
for (const match of xml.matchAll(nodePattern)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
if (!coordinate.every(Number.isFinite)) continue;
nodes.set(attrs.id, coordinate);
const tags = parseTags(match[2] || "");
if (tags.highway === "traffic_signals") {
trafficSignalControls.push({ id: attrs.id, longitude: coordinate[0], latitude: coordinate[1], tags });
}
}
const ways = [];
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete") continue;
const body = match[2];
const refs = [];
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
const ref = xmlAttrs(ndMatch[1]).ref;
if (ref && nodes.has(ref)) refs.push(ref);
}
if (refs.length >= 2) ways.push({ id: attrs.id || `way-${ways.length + 1}`, refs, tags: parseTags(body) });
}
for (const control of trafficSignalControls) {
const arms = [];
for (const way of ways) {
if (!isMotorRoad(way.tags)) continue;
for (let index = 0; index < way.refs.length; index += 1) {
if (way.refs[index] !== control.id) continue;
for (const neighborIndex of [index - 1, index + 1]) {
const neighbor = way.refs[neighborIndex];
if (!neighbor || !nodes.has(neighbor)) continue;
const neighborPoint = nodes.get(neighbor);
arms.push({
headingDegrees: headingBetween(control, neighborPoint),
wayId: String(way.id),
neighborNodeId: String(neighbor),
});
}
}
}
control.arms = dedupeHeadings(arms);
control.junctionType = control.arms.length === 3 ? "T" : control.arms.length === 4 ? "cross" : "other";
}
return { bounds, nodes, ways, trafficSignalControls };
}
function isMotorRoad(tags) {
const highway = tags.highway || "";
return highway && tags.area !== "yes" && !new Set([
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track",
"bridleway", "corridor", "elevator", "platform", "construction",
]).has(highway);
}
function headingBetween(from, to) {
const latitude = (from.latitude + to[1]) / 2 * Math.PI / 180;
return Math.atan2((to[0] - from.longitude) * Math.cos(latitude), to[1] - from.latitude) * 180 / Math.PI;
}
function dedupeHeadings(arms) {
const normalized = (value) => ((value % 360) + 360) % 360;
const distance = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
const result = [];
for (const arm of arms) {
arm.headingDegrees = normalized(arm.headingDegrees);
if (!result.some((other) => distance(other.headingDegrees, arm.headingDegrees) <= 25)) result.push(arm);
}
return result.sort((a, b) => a.headingDegrees - b.headingDegrees);
}
function xmlAttrs(text) {
const attrs = {};
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
attrs[match[1]] = match[2] !== undefined ? match[2] : match[3];
}
return attrs;
}
function parseTags(body) {
const tags = {};
for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
const tag = xmlAttrs(match[1]);
if (tag.k) tags[tag.k] = tag.v || "";
}
return tags;
}
module.exports = { parseOsm };

View File

@@ -1,231 +0,0 @@
"use strict";
const fs = require("fs");
const PI = Math.PI;
const EARTH_A = 6378245.0;
const EARTH_EE = 0.00669342162296594323;
function transformLat(x, y) {
let value = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
value += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3;
value += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3;
return value;
}
function transformLon(x, y) {
let value = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
value += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3;
value += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3;
return value;
}
// This is the standard local inverse approximation used for GCJ-02 reference
// data. It is intentionally kept separate from native road geometry, whose
// source coordinates remain WGS84.
function gcj02ToWgs84(coordinate) {
const [longitude, latitude] = coordinate;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error("Reference coordinate must be finite");
const dLat = transformLat(longitude - 105, latitude - 35);
const dLon = transformLon(longitude - 105, latitude - 35);
const radLat = latitude / 180 * PI;
const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2;
const sqrtMagic = Math.sqrt(magic);
return [
longitude - dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI),
latitude - dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI),
];
}
function mapCoordinates(coordinates, mapper) {
if (typeof coordinates[0] === "number") return mapper(coordinates);
return coordinates.map((value) => mapCoordinates(value, mapper));
}
function convertGeoJson(document) {
if (!document || document.type !== "FeatureCollection" || !Array.isArray(document.features)) {
throw new Error("Reference must be a GeoJSON FeatureCollection");
}
return {
...document,
crs: undefined,
features: document.features.map((feature) => {
if (!feature || !feature.geometry || !feature.geometry.coordinates) throw new Error("Reference feature is missing geometry");
return { ...feature, geometry: { ...feature.geometry, coordinates: mapCoordinates(feature.geometry.coordinates, gcj02ToWgs84) } };
}),
};
}
function coordinatesOf(document) {
const points = [];
for (const feature of document.features || []) walkCoordinates(feature.geometry?.coordinates, points);
return points;
}
function walkCoordinates(value, points) {
if (!Array.isArray(value) || !value.length) return;
if (typeof value[0] === "number") {
points.push(value);
return;
}
for (const child of value) walkCoordinates(child, points);
}
function boundsOf(document) {
const points = coordinatesOf(document);
if (!points.length) throw new Error("Reference contains no coordinates");
return {
minLon: Math.min(...points.map((point) => point[0])),
minLat: Math.min(...points.map((point) => point[1])),
maxLon: Math.max(...points.map((point) => point[0])),
maxLat: Math.max(...points.map((point) => point[1])),
};
}
function centerOf(bounds) {
return [(bounds.minLon + bounds.maxLon) / 2, (bounds.minLat + bounds.maxLat) / 2];
}
function distanceMeters(first, second) {
const lonScale = 111320 * Math.cos(first[1] * PI / 180);
return Math.hypot((second[0] - first[0]) * lonScale, (second[1] - first[1]) * 111320);
}
function parseOsmNodes(xml) {
const nodes = [];
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
const attrs = {};
for (const item of match[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[item[1]] = item[2] ?? item[3];
if (!attrs.id || !Number.isFinite(Number(attrs.lon)) || !Number.isFinite(Number(attrs.lat))) continue;
const tags = {};
for (const item of (match[2] || "").matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
const tag = {};
for (const attr of item[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) tag[attr[1]] = attr[2] ?? attr[3];
if (tag.k) tags[tag.k] = tag.v || "";
}
nodes.push({ id: String(attrs.id), coordinate: [Number(attrs.lon), Number(attrs.lat)], tags });
}
return nodes;
}
function nearestNode(nodes, coordinate, nodeId) {
if (nodeId) {
const exact = nodes.find((node) => node.id === String(nodeId));
if (!exact) throw new Error(`OSM node not found: ${nodeId}`);
return { ...exact, distanceMeters: distanceMeters(exact.coordinate, coordinate), match: "node-id" };
}
const candidates = nodes.map((node) => ({ ...node, distanceMeters: distanceMeters(node.coordinate, coordinate) }));
candidates.sort((first, second) => first.distanceMeters - second.distanceMeters);
if (!candidates[0]) throw new Error("OSM contains no usable nodes");
return { ...candidates[0], match: "nearest-node" };
}
function bboxIntersectionRatio(first, second) {
const width = Math.max(0, Math.min(first.maxLon, second.maxLon) - Math.max(first.minLon, second.minLon));
const height = Math.max(0, Math.min(first.maxLat, second.maxLat) - Math.max(first.minLat, second.minLat));
const intersection = width * height;
const firstArea = Math.max(0, first.maxLon - first.minLon) * Math.max(0, first.maxLat - first.minLat);
const secondArea = Math.max(0, second.maxLon - second.minLon) * Math.max(0, second.maxLat - second.minLat);
return intersection / Math.max(firstArea + secondArea - intersection, Number.EPSILON);
}
// A complex junction is compiled as one cluster of `complex_part` polygons in
// road_surface.geojson, not as a per-node feature in intersection_surface.
// Match it by cluster id, or by whichever cluster core sits nearest the node.
function complexClusterSurface(nativeRoadSurfaceFile, node, clusterId) {
if (!nativeRoadSurfaceFile || !fs.existsSync(nativeRoadSurfaceFile)) return null;
const surface = JSON.parse(fs.readFileSync(nativeRoadSurfaceFile, "utf8"));
const parts = (surface.features || []).filter((item) => item.properties?.cluster_id && item.properties?.complex_part);
const cores = parts.filter((item) => item.properties.complex_part === "core" && Array.isArray(item.properties.center));
if (!cores.length) return null;
const core = clusterId
? cores.find((item) => String(item.properties.cluster_id) === String(clusterId))
: [...cores].sort((first, second) => distanceMeters(first.properties.center, node.coordinate) - distanceMeters(second.properties.center, node.coordinate))[0];
if (!core) return null;
const features = parts.filter((item) => item.properties.cluster_id === core.properties.cluster_id);
return { clusterId: core.properties.cluster_id, core, features };
}
function inspectReference({ referenceFile, osmFile, nativeIntersectionFile, nativeRoadSurfaceFile, nodeId, clusterId }) {
const source = JSON.parse(fs.readFileSync(referenceFile, "utf8"));
const converted = convertGeoJson(source);
const referenceBounds = boundsOf(converted);
const referenceCenter = centerOf(referenceBounds);
const nodes = parseOsmNodes(fs.readFileSync(osmFile, "utf8"));
const matchedNode = nearestNode(nodes, referenceCenter, nodeId);
const native = JSON.parse(fs.readFileSync(nativeIntersectionFile, "utf8"));
const feature = (native.features || []).find((item) => item.properties?.osm_node_id === matchedNode.id);
const cluster = feature ? null : complexClusterSurface(nativeRoadSurfaceFile, matchedNode, clusterId);
const matchedFeatures = feature ? [feature] : cluster?.features || null;
const nativeBounds = matchedFeatures ? boundsOf({ features: matchedFeatures }) : null;
const diagnostics = [];
if (!matchedFeatures) diagnostics.push(nativeRoadSurfaceFile ? "No native intersection surface or complex cluster matched the OSM node" : "No native intersection surface matched the OSM node; pass --native-road-surface to also search complex junction clusters");
return {
schema: "gaode-junction-reference-comparison/v2",
source: { file: referenceFile, coordinateSystem: "GCJ-02", featureCount: converted.features.length },
conversion: { target: "WGS84", method: "gcj02-inverse-approximation" },
reference: { bounds: referenceBounds, center: referenceCenter },
matchedOsmNode: { id: matchedNode.id, coordinate: matchedNode.coordinate, tags: matchedNode.tags, match: matchedNode.match, centerDistanceMeters: matchedNode.distanceMeters },
nativeIntersection: nativeBounds ? {
kind: feature ? "junction-node" : "complex-cluster",
clusterId: cluster?.clusterId || null,
featureCount: matchedFeatures.length,
bounds: nativeBounds,
bboxIoU: bboxIntersectionRatio(referenceBounds, nativeBounds),
centerOffsetMeters: distanceMeters(referenceCenter, centerOf(nativeBounds)),
featureProperties: feature ? feature.properties : cluster.core.properties,
} : null,
diagnostics,
converted,
matchedFeatures,
};
}
function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters = 180 }) {
const width = 1000;
const height = 1000;
const lonScale = 111320 * Math.cos(center[1] * PI / 180);
const project = (point) => [
width / 2 + (point[0] - center[0]) * lonScale * width / (radiusMeters * 2),
height / 2 - (point[1] - center[1]) * 111320 * height / (radiusMeters * 2),
];
const pathFor = (coordinates) => {
const parts = [];
const appendLine = (line, close) => {
if (!line?.length) return;
const [firstX, firstY] = project(line[0]);
parts.push(`M ${firstX.toFixed(1)} ${firstY.toFixed(1)}`);
for (const point of line.slice(1)) {
const [x, y] = project(point);
parts.push(`L ${x.toFixed(1)} ${y.toFixed(1)}`);
}
if (close) parts.push("Z");
};
const visit = (value) => {
if (!Array.isArray(value) || !value.length) return;
if (typeof value[0] === "number") return;
if (typeof value[0][0] === "number") appendLine(value, value.length > 2);
else value.forEach(visit);
};
visit(coordinates);
return parts.join(" ");
};
const color = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" };
const references = converted.features.map((feature) => {
const type = feature.properties?.type || "unknown";
return `<path d="${pathFor(feature.geometry.coordinates)}" fill="${feature.geometry.type.includes("Polygon") ? `${color[type] || "#334155"}18` : "none"}" stroke="${color[type] || "#334155"}" stroke-width="1.2"/>`;
}).join("\n");
const nativePaths = (nativeIntersection?.features || []).map((feature) => `<path d="${pathFor(feature.geometry.coordinates)}" fill="#dc262655" stroke="#dc2626" stroke-width="3"/>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#f8fafc"/>
${references}
${nativePaths}
<circle cx="${width / 2}" cy="${height / 2}" r="5" fill="#111827"/>
<text x="20" y="35" font-family="sans-serif" font-size="22" fill="#111827">Gaode reference (type colors) / native intersection (red)</text>
</svg>`;
}
module.exports = { gcj02ToWgs84, convertGeoJson, boundsOf, parseOsmNodes, nearestNode, inspectReference, localReferenceSvg };

View File

@@ -1,330 +0,0 @@
"use strict";
const fs = require("fs");
const crypto = require("crypto");
const { parseOsm } = require("./osm");
const EARTH_RADIUS = 6371008.8;
const CURB_OFFSET_METERS = 5.2;
const MAST_REACH_METERS = 4.5;
const SIGNAL_LAYOUT = Object.freeze({
poleHeightMeters: 6.7, poleRadiusMeters: 0.13, armWidthMeters: 0.21,
mastHeightMeters: 6.25, headCenterHeightMeters: 6.25,
headWidthMeters: 0.68, headDepthMeters: 0.30, headBodyHeightMeters: 1.62,
lensRadiusMeters: 0.22, lensDepthMeters: 0.07, lensFaceOffsetMeters: 0.18,
lensVerticalOffsetsMeters: [0.49, -0.01, -0.51],
countdownLateralMeters: 1.15, countdownFaceOffsetMeters: 0.05,
countdownWidthMeters: 0.82, countdownDepthMeters: 0.14,
countdownHeightMeters: 0.56, countdownVerticalOffsetMeters: 0.0,
});
function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
const centers = (intersections.features || []).map((feature, index) => {
const point = polygonCenter(feature.geometry);
return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) };
}).filter((entry) => entry.point);
const clusteredStops = new Map();
for (const feature of stopLines.features || []) {
const clusterId = feature.properties?.cluster_id;
const point = polygonCenter(feature.geometry);
if (!clusterId || !point) continue;
if (!clusteredStops.has(clusterId)) clusteredStops.set(clusterId, []);
clusteredStops.get(clusterId).push(point);
}
for (const [clusterId, points] of clusteredStops) {
if (points.length < 3) continue;
const point = points.reduce((sum, item) => [sum[0] + item[0] / points.length, sum[1] + item[1] / points.length], [0, 0]);
centers.push({ id: `cluster-${clusterId}`, clusterId, point, radius: Math.max(...points.map((item) => metersBetween(point, item))) });
}
const candidates = [];
for (const feature of stopLines.features || []) {
const center = polygonCenter(feature.geometry);
if (!center) continue;
const clusterId = feature.properties?.cluster_id;
const intersection = clusterId ? centers.find((entry) => entry.clusterId === clusterId) : nearestCenter(center, centers);
if (!intersection || metersBetween(center, intersection.point) > 32) continue;
const axis = roadAxis(feature.geometry, center, intersection.point);
if (!axis) continue;
const right = [axis[1], -axis[0]];
candidates.push({
intersectionId: intersection.id, center, axis,
point: intersection.clusterId
? moveMeters(center, right, CURB_OFFSET_METERS)
: moveMeters(moveMeters(intersection.point, axis, intersection.radius + 3.2), right, CURB_OFFSET_METERS),
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
matchHeadingDegrees: intersection.clusterId
? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI)
: null,
});
}
const features = [];
for (const control of controls) {
const controlPoint = [Number(control.longitude), Number(control.latitude)];
if (!controlPoint.every(Number.isFinite) || !Array.isArray(control.arms) || control.arms.length < 3) continue;
const intersection = nearestCenter(controlPoint, centers);
if (!intersection || metersBetween(controlPoint, intersection.point) > 32) continue;
const arms = matchOsmArms(candidates.filter((item) => item.intersectionId === intersection.id), controlPoint, control.arms);
const groups = phaseGroups(arms);
arms.forEach((candidate, index) => {
const fallbackArmId = `heading-${Math.round(normalizeDegrees(candidate.osmArm?.headingDegrees || 0) * 1000)}`;
const sourceWayId = String(candidate.osmArm?.wayId || "legacy");
const neighborNodeId = String(candidate.osmArm?.neighborNodeId || fallbackArmId);
const approachId = `${sourceWayId}:${neighborNodeId}`;
const signalUid = `osm-${String(control.id)}-${sourceWayId}-${neighborNodeId}`;
features.push({
type: "Feature",
geometry: { type: "Point", coordinates: candidate.point.slice() },
properties: {
signal_uid: signalUid, display_id: signalUid, control_id: String(control.id),
approach_id: approachId, source_way_id: sourceWayId,
// These are independent assembly controls. heading_deg remains a
// migration hint for older native documents only.
mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90),
face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180),
phase_group: groups[index],
mast_reach_m: MAST_REACH_METERS,
stop_lon: candidate.center[0], stop_lat: candidate.center[1],
enabled: true, z_offset_m: 0,
},
});
});
}
return validateTrafficSignalFeatures({ type: "FeatureCollection", features });
}
function validateTrafficSignalFeatures(collection) {
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
throw new Error("Traffic signal assemblies must be a FeatureCollection");
}
const uids = new Set();
const displayIds = new Set();
const features = collection.features.map((feature, index) => {
const label = `traffic signal feature ${index + 1}`;
if (feature?.geometry?.type !== "Point" || !Array.isArray(feature.geometry.coordinates) ||
feature.geometry.coordinates.length < 2 || !feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)) {
throw new Error(`${label}: geometry must be a finite Point`);
}
const input = feature.properties || {};
const text = (key, required = true) => {
const value = input[key] == null ? "" : String(input[key]).trim();
if (required && !value) throw new Error(`${label}: missing ${key}`);
return value;
};
const number = (key, options = {}) => {
if (input[key] === null || input[key] === undefined || input[key] === "") {
throw new Error(`${label}: missing ${key}`);
}
const value = Number(input[key]);
if (!Number.isFinite(value) || (options.min != null && value < options.min) || (options.max != null && value > options.max)) {
throw new Error(`${label}: invalid ${key} '${input[key]}'`);
}
return value;
};
const signalUid = text("signal_uid");
if (!/^osm-[A-Za-z0-9_.:-]+$/.test(signalUid)) throw new Error(`${label}: invalid signal_uid '${signalUid}'`);
if (uids.has(signalUid)) throw new Error(`Duplicate signal_uid '${signalUid}'`);
uids.add(signalUid);
const displayId = text("display_id", false);
if (displayId && displayIds.has(displayId)) throw new Error(`Duplicate display_id '${displayId}'`);
if (displayId) displayIds.add(displayId);
const phaseGroup = number("phase_group", { min: 0, max: 1 });
if (!Number.isInteger(phaseGroup)) throw new Error(`${label}: phase_group must be 0 or 1`);
const enabled = normalizeBoolean(input.enabled, label);
const controlId = text("control_id");
const approachId = text("approach_id");
const sourceWayId = text("source_way_id");
if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`);
const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`;
if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`);
const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg"));
if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) {
throw new Error(`${label}: missing mast_heading_deg`);
}
if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) {
throw new Error(`${label}: missing face_heading_deg`);
}
const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === ""
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90)
: normalizeDegrees(number("mast_heading_deg"));
const faceHeading = input.face_heading_deg == null || input.face_heading_deg === ""
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180)
: normalizeDegrees(number("face_heading_deg"));
return {
type: "Feature",
geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) },
properties: {
...input, signal_uid: signalUid, display_id: displayId,
control_id: controlId, approach_id: approachId,
source_way_id: sourceWayId,
// Retain the legacy value only for migration compatibility. Runtime
// geometry is entirely defined by mast_heading_deg and face_heading_deg.
heading_deg: legacyHeading,
mast_heading_deg: mastHeading, face_heading_deg: faceHeading,
phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }),
stop_lon: number("stop_lon", { min: -180, max: 180 }),
stop_lat: number("stop_lat", { min: -90, max: 90 }),
enabled, z_offset_m: number("z_offset_m", { min: -20, max: 100 }),
},
};
});
return { type: "FeatureCollection", features };
}
function buildTrafficSignalsFromFeatures(collection) {
const normalized = validateTrafficSignalFeatures(collection);
const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => {
const p = feature.properties;
const point = feature.geometry.coordinates;
const mastAxis = headingVector(p.mast_heading_deg);
return {
id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id,
nodeKey: signalNodeKey(p.signal_uid),
controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id,
phaseGroup: p.phase_group, longitude: point[0], latitude: point[1],
stopLongitude: p.stop_lon, stopLatitude: p.stop_lat,
// Existing Blender readers require headingDegrees. It is a compatibility
// alias only; the independent mast/face fields below define all geometry.
headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg,
mastHeadingDegrees: p.mast_heading_deg,
faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m,
zOffsetMeters: p.z_offset_m,
pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m),
};
});
return { version: 3, layout: SIGNAL_LAYOUT, signals };
}
function signalNodeKey(signalUid) {
return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`;
}
function reconcileTrafficSignalSourceReferences(collection, controls) {
const normalized = validateTrafficSignalFeatures(collection);
const approachesByControl = new Map((controls || []).map((control) => [
String(control.id),
new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)),
]));
const kept = [];
const dropped = [];
for (const [index, feature] of normalized.features.entries()) {
const { control_id: controlId, approach_id: approachId, signal_uid: signalUid } = feature.properties;
const approaches = approachesByControl.get(controlId);
if (!approaches) {
dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-control", message: `control_id '${controlId}' is not present in the current OSM` });
continue;
}
if (!approaches.has(approachId)) {
dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-approach", message: `approach_id '${approachId}' is not present on OSM control '${controlId}'` });
continue;
}
kept.push(feature);
}
return { collection: { ...normalized, features: kept }, dropped };
}
function validateTrafficSignalSourceReferences(collection, controls) {
const { collection: reconciled, dropped } = reconcileTrafficSignalSourceReferences(collection, controls);
if (dropped.length) throw new Error(`traffic signal feature ${dropped[0].index}: ${dropped[0].message}`);
return reconciled;
}
function buildTrafficSignals(stopLines, intersections, controls = []) {
return buildTrafficSignalsFromFeatures(buildTrafficSignalFeatures(stopLines, intersections, controls));
}
function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) {
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
return buildTrafficSignalFeatures(
JSON.parse(fs.readFileSync(stopLinePath, "utf8")),
JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls,
);
}
function readTrafficSignals(editablePath, osmPath = null) {
const collection = JSON.parse(fs.readFileSync(editablePath, "utf8"));
if (osmPath) {
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
validateTrafficSignalSourceReferences(collection, controls);
}
return buildTrafficSignalsFromFeatures(collection);
}
function normalizeBoolean(value, label) {
if (value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true" || String(value).toLowerCase() === "yes") return true;
if (value === false || value === 0 || value === "0" || String(value).toLowerCase() === "false" || String(value).toLowerCase() === "no") return false;
throw new Error(`${label}: invalid enabled '${value}'`);
}
function uniqueApproachArms(candidates, controlPoint) {
const sorted = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)), controlDistance: metersBetween(controlPoint, candidate.center) }))
.sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance);
const arms = [];
for (const candidate of sorted) if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate);
return arms;
}
function matchOsmArms(candidates, controlPoint, osmArms) {
const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: candidate.matchHeadingDegrees ?? normalizeDegrees(headingBetween(controlPoint, candidate.center)) }));
if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint);
return osmArms.map((osmArm) => {
let bestIndex = -1; let bestDistance = Infinity;
remaining.forEach((item, index) => {
const directedDistance = angularDistance(item.armHeading, osmArm.headingDegrees);
const distance = item.matchHeadingDegrees == null
? directedDistance
: Math.min(
angularDistance(item.matchHeadingDegrees, osmArm.headingDegrees),
angularDistance(item.matchHeadingDegrees + 180, osmArm.headingDegrees),
);
if (distance < bestDistance) { bestDistance = distance; bestIndex = index; }
});
const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm);
return { ...candidate, osmArm };
});
}
function fallbackCandidate(controlPoint, osmArm) {
const outward = headingVector(osmArm.headingDegrees); const axis = [-outward[0], -outward[1]];
const center = moveMeters(controlPoint, outward, 8); const farSide = moveMeters(controlPoint, axis, 3.2);
return { center, axis, point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS), armHeading: normalizeDegrees(osmArm.headingDegrees), headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), fallback: true };
}
function phaseGroups(arms) {
const groups = Array(arms.length).fill(1); if (arms.length < 2) return groups;
let main = [0, 1]; let best = -1;
for (let a = 0; a < arms.length; a += 1) for (let b = a + 1; b < arms.length; b += 1) { const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading); if (opposition > best) { best = opposition; main = [a, b]; } }
groups[main[0]] = 0; groups[main[1]] = 0; return groups;
}
function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) {
const face = headingVector(faceHeadingDegrees);
const head = moveMeters(pole, mastAxis, mastReach);
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset });
const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
const faceRight = [-face[1], face[0]];
const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters);
return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } };
}
function polygonCenter(geometry) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; if (!ring || ring.length < 4) return null; const points = ring.slice(0, -1); return [points.reduce((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length]; }
function polygonRadius(geometry, center) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0; }
function roadAxis(geometry, center, target) { const ring = geometry?.coordinates?.[0]; if (!ring || ring.length < 3) return null; let longest; for (let i = 0; i < ring.length - 1; i += 1) { const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180); const dy = ring[i + 1][1] - ring[i][1]; const length = Math.hypot(dx, dy); if (!longest || length > longest.length) longest = { dx, dy, length }; } if (!longest?.length) return null; let axis = [-longest.dy / longest.length, longest.dx / longest.length]; const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]]; if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]]; return axis; }
function nearestCenter(point, centers) { return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null; }
function metersBetween(a, b) { const lat = (a[1] + b[1]) / 2 * Math.PI / 180; return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS; }
function moveMeters(point, vector, meters) { const scale = 180 / Math.PI / EARTH_RADIUS; return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale]; }
function headingBetween(from, to) { const latitude = (from[1] + to[1]) / 2 * Math.PI / 180; return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI; }
function headingVector(degrees) { const radians = degrees * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; }
function normalizeDegrees(value) { return ((value % 360) + 360) % 360; }
function angularDistance(a, b) { return Math.abs(((a - b + 540) % 360) - 180); }
module.exports = {
SIGNAL_LAYOUT,
signalNodeKey,
buildTrafficSignalFeatures,
validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences,
buildTrafficSignalsFromFeatures,
buildTrafficSignals,
readTrafficSignalFeatures,
readTrafficSignals,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +0,0 @@
"use strict";
const assert = require("assert/strict");
const fs = require("fs");
const path = require("path");
const compiler = require("../src");
assert.equal(typeof compiler.laneGeometry.laneCenterline, "function");
assert.equal(typeof compiler.gaodeReference.convertGeoJson, "function");
assert.equal(typeof compiler.turnLaneArrows.buildCustomTurnLaneArrows, "function");
assert.equal(typeof compiler.complexJunction.buildComplexJunctionGeometry, "function");
assert.equal(typeof compiler.nativeRoad.compileRoadModel, "function");
assert.equal(typeof compiler.check.checkOutput, "function");
const fixture = path.join(__dirname, "fixtures", "fengshu-er-road.osm");
assert.ok(fs.existsSync(fixture));
assert.throws(() => compiler.compiler.validateInput({ id: "area" }), /RoadCompilerInput/);
const model = compiler.nativeRoad.compileRoadModel(fs.readFileSync(fixture, "utf8"), { schema: "native-road-overrides/v1", overrides: [] });
assert.equal(model.roads.length, 24);
console.log("road compiler package tests passed");

View File

@@ -1 +0,0 @@
*{box-sizing:border-box}body{margin:0;background:#eef1ef;color:#202523;font:14px system-ui,sans-serif}header{height:50px;display:flex;gap:12px;align-items:center;padding:0 16px;background:#183a32;color:#fff}header span{color:#c9d8d2}#dirty-state.dirty{color:#ffe08a;font-weight:700}button{border:1px solid #82988f;background:#fff;color:#1d392f;padding:7px 10px;border-radius:3px;cursor:pointer}button:disabled{cursor:default;opacity:.55}header button:first-of-type{margin-left:auto}main{display:grid;grid-template-columns:260px minmax(0,1fr) 320px;height:calc(100vh - 50px)}aside{overflow:auto;background:#fff;padding:16px}.issues{border-right:1px solid #d5dfda}.inspector{border-left:1px solid #d5dfda}.map{position:relative;background:#d7e2de;min-height:400px}canvas{width:100%;height:100%;display:block}.legend{position:absolute;bottom:12px;left:12px;background:#fff;padding:8px;box-shadow:0 1px 4px #0003}.legend i{display:inline-block;width:18px;height:7px;margin:0 4px -1px 10px}.reference{background:#a5b0b5}.native{background:#296654}.line{height:3px!important;background:#263630}.junction{width:10px!important;height:10px!important;background:#0e7860;border-radius:50%}.warning{width:10px!important;height:10px!important;background:#d49318;border-radius:50%}h1{font-size:16px;margin:0 0 8px}h2{font-size:14px;margin:12px 0 8px}.muted,output,pre{color:#52615b}.issues ul{list-style:none;padding:0;margin:0}.issues button{width:100%;text-align:left;margin:4px 0;background:#fff7e5;border-color:#e7c67b;color:#693c00}.issues button.error{background:#fff0ee;border-color:#e3a49b;color:#8d261a}.segmented{display:flex;margin:0 0 8px}.segmented button{flex:1;border-radius:0;padding:6px 4px;font-size:12px}.segmented button+button{border-left:0}.segmented button:first-child{border-radius:3px 0 0 3px}.segmented button:last-child{border-radius:0 3px 3px 0}.segmented button.active{background:#286956;border-color:#286956;color:#fff}label{display:block;margin:10px 0}input[type=number]{display:block;width:100%;padding:7px;border:1px solid #aab8b2;border-radius:2px}output,pre{display:block;white-space:pre-wrap;overflow-wrap:anywhere}form button{margin-top:8px;background:#286956;color:white;border:0}dl{display:grid;grid-template-columns:1fr auto;gap:5px 10px;margin:0}dt{color:#52615b}dd{margin:0;font-variant-numeric:tabular-nums}hr{border:0;border-top:1px solid #dde4e1;margin:16px 0}details{margin-top:16px}summary{cursor:pointer;font-weight:600}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr)}.issues{display:none}.inspector{position:absolute;right:0;bottom:0;width:min(360px,100%);max-height:55vh;border-top:1px solid #d5dfda}}

View File

@@ -1,459 +0,0 @@
import Map from "/vendor/ol/Map.js";
import View from "/vendor/ol/View.js";
import VectorLayer from "/vendor/ol/layer/Vector.js";
import VectorSource from "/vendor/ol/source/Vector.js";
import GeoJSON from "/vendor/ol/format/GeoJSON.js";
import Feature from "/vendor/ol/Feature.js";
import LineString from "/vendor/ol/geom/LineString.js";
import Point from "/vendor/ol/geom/Point.js";
import Style from "/vendor/ol/style/Style.js";
import Fill from "/vendor/ol/style/Fill.js";
import Stroke from "/vendor/ol/style/Stroke.js";
import CircleStyle from "/vendor/ol/style/Circle.js";
import Text from "/vendor/ol/style/Text.js";
import Polygon from "/vendor/ol/geom/Polygon.js";
import RegularShape from "/vendor/ol/style/RegularShape.js";
import Select from "/vendor/ol/interaction/Select.js";
import { click } from "/vendor/ol/events/condition.js";
import { fromLonLat } from "/vendor/ol/proj.js";
const geojson = new GeoJSON();
const areaLabel = document.querySelector("#area");
const status = document.querySelector("#status");
const form = document.querySelector("#road-form");
const hint = document.querySelector("#hint");
const roadName = document.querySelector("#road-name");
const movementSummary = document.querySelector("#movement-summary");
const laneConvention = document.querySelector("#lane-convention");
const selectedMovementPanel = document.querySelector("#selected-movement");
const movementDetail = document.querySelector("#movement-detail");
const directionSwitch = document.querySelector("#direction-switch");
const widthInput = document.querySelector("#width");
const lanesInput = document.querySelector("#lanes");
const leftInput = document.querySelector("#left");
const rightInput = document.querySelector("#right");
const centerLineForm = document.querySelector("#center-line-form");
const centerLineSegment = document.querySelector("#center-line-segment");
const markingStyleHeading = document.querySelector("#marking-style-heading");
const centerLineStyleInput = document.querySelector("#center-line-style");
const doubleYellowOption = document.createElement("option");
doubleYellowOption.value = "double-yellow-solid"; doubleYellowOption.textContent = "双黄实线"; centerLineStyleInput.append(doubleYellowOption);
const evidence = document.querySelector("#evidence");
const diagnostics = document.querySelector("#diagnostics");
const diagnosticFilters = document.querySelector("#diagnostic-filters");
const summary = document.querySelector("#summary");
const connectionsBox = document.querySelector("#connections");
const addConnectionButton = document.querySelector("#add-connection");
const saveButton = document.querySelector("#save");
const compileButton = document.querySelector("#compile");
const dirtyState = document.querySelector("#dirty-state");
const scenePreviewToggle = document.querySelector("#scene-preview");
const selectedJunctionPanel = document.querySelector("#selected-junction");
const junctionDetail = document.querySelector("#junction-detail");
const directionArrowsToggle = document.createElement("label");
directionArrowsToggle.innerHTML = '<input data-layer="directionArrows" type="checkbox" checked> 道路方向箭头';
const markingsToggle = document.createElement("label");
markingsToggle.innerHTML = '<input data-layer="markings" type="checkbox" checked> 车道分隔线与路口转向箭头';
const centerLinesToggle = document.createElement("label");
centerLinesToggle.innerHTML = '<input data-layer="centerLines" type="checkbox" checked> 道路中心线';
const edgeLinesToggle = document.createElement("label");
edgeLinesToggle.innerHTML = '<input data-layer="edgeLines" type="checkbox"> 道路外缘线';
const controlsToggle = document.createElement("label");
controlsToggle.innerHTML = '<input data-layer="controls" type="checkbox" checked> 斑马线与停止线';
const signalsToggle = document.createElement("label");
signalsToggle.innerHTML = '<input data-layer="signals" type="checkbox" checked> 红绿灯设施';
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle, signalsToggle);
// Only offered when the server was started with --debug; without it the state
// carries no candidates and an empty toggle would just be confusing.
const candidateAction = document.createElement("section");
document.querySelector(".inspector").insertBefore(candidateAction, document.querySelector(".inspector details"));
const candidatesToggle = document.createElement("label");
candidatesToggle.hidden = true;
candidatesToggle.innerHTML = '<input data-layer="junctionCandidates" type="checkbox" checked> 复杂路口候选debug';
signalsToggle.after(candidatesToggle);
const gaodeReferenceColors = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" };
let state;
let selectedRoad = null;
let selectedMovement = null;
let selectedJunction = null;
let staged = [];
let manualFromEndpoint = null;
let diagnosticFilter = "all";
let scenePreview = false;
let selectedCenterLineSegment = null;
let selectedLaneSeparator = null;
let selectedEdgeLine = null;
let selectedSignal = null;
const signalPanel = document.createElement("section");
signalPanel.innerHTML = '<hr><h2>原生红绿灯</h2><button type="button" data-signal="generate">从 OSM 生成缺失信号灯</button><label>检查信号灯<select name="signal-picker"><option value="">选择设施</option></select></label><form hidden><output></output><label>灯杆经度<input name="lon" type="number" min="-180" max="180" step="0.000001"></label><label>灯杆纬度<input name="lat" type="number" min="-90" max="90" step="0.000001"></label><label>横杆方向(度)<input name="mastHeading" type="number" min="0" max="360" step="1"></label><label>横杆长度(米)<input name="mastReach" type="number" min="0.1" max="30" step="0.1"></label><label>灯面朝向(度)<input name="faceHeading" type="number" min="0" max="360" step="1"></label><label>相位组<input name="phase" type="number" min="0" max="1" step="1"></label><label><input name="enabled" type="checkbox"> 启用</label><button type="submit">保存信号灯</button><button type="button" data-signal="delete">删除信号灯</button></form>';
document.querySelector(".inspector").insertBefore(signalPanel, document.querySelector(".inspector details"));
const signalForm = signalPanel.querySelector("form");
const signalOutput = signalForm.querySelector("output");
const signalPicker = signalPanel.querySelector('[name="signal-picker"]');
const source = () => new VectorSource();
const layers = {
reference: new VectorLayer({ source: source(), visible: false, style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }),
gaodeReference: new VectorLayer({ source: source(), visible: true, zIndex: 1, style: (feature) => { const color = gaodeReferenceColors[feature.get("type")] || "#475569"; return new Style({ fill: new Fill({ color: `${color}26` }), stroke: new Stroke({ color, width: 1.5 }) }); } }),
native: new VectorLayer({ source: source(), style: nativeSurfaceStyle }),
edgeLines: new VectorLayer({ source: source(), visible: false, style: markingStyle }),
sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }),
osm: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#263630", width: feature.get("road_id") === selectedRoad?.id ? 5 : 2 }) }) }),
lanes: new VectorLayer({ source: source(), style: laneStyle }),
directionArrows: new VectorLayer({ source: source(), style: markingStyle }),
markings: new VectorLayer({ source: source(), style: markingStyle }),
centerLines: new VectorLayer({ source: source(), style: centerLineStyle }),
controls: new VectorLayer({ source: source(), style: markingStyle }),
signals: new VectorLayer({ source: source(), style: signalAssemblyStyle, zIndex: 30 }),
osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }),
connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }),
junctionCandidates: new VectorLayer({ source: source(), visible: true, zIndex: 25, style: (feature) => [
new Style({ fill: new Fill({ color: "rgba(219, 39, 119, .12)" }), stroke: new Stroke({ color: "#db2777", width: 2, lineDash: [8, 5] }) }),
new Style({ text: new Text({ text: `#${feature.get("index")} ${feature.get("nodeCount")}节点`, font: "bold 13px system-ui, sans-serif", fill: new Fill({ color: "#831843" }), stroke: new Stroke({ color: "#fff", width: 3 }), offsetY: -14 }) }),
] }),
diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }),
selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }),
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
};
const map = new Map({ target: "map", layers: [layers.gaodeReference, layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.junctionCandidates, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) });
const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.junctionCandidates, layers.diagnostics].includes(layer), hitTolerance: 12, style: null });
map.addInteraction(select);
select.on("select", ({ selected }) => {
const feature = selected[0];
if (!feature) return;
if (feature.get("candidate_index")) return selectJunctionCandidate(feature);
if (feature.get("signal_uid")) return selectSignal(poleFeatureForSignal(feature.get("signal_uid")) || feature);
if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature));
const junction = junctionForFeature(feature);
if (junction) return selectJunction(junction);
const provenance = feature.get("provenance");
if (provenance?.startsWith("native-road-")) {
const road = roadForFeature(feature);
const directionArrow = provenance === "native-road-direction-arrow/v1";
const turnArrow = provenance === "native-road-turn-arrow/v1";
const centerLine = provenance === "native-road-center-line/v1";
const edgeLine = provenance === "native-road-edge-line/v1";
const crosswalk = provenance === "native-road-crosswalk/v1";
const stopLine = provenance === "native-road-stop-line/v1";
const markingType = centerLine ? "道路中心线" : edgeLine ? "道路外缘线" : crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线";
selectRoad(road);
if (centerLine) selectCenterLine(feature); else if (edgeLine) selectEdgeLine(feature); else if (provenance === "native-road-lane-separator/v1") selectLaneSeparator(feature); else clearCenterLineSelection();
evidence.textContent = JSON.stringify({
标线类型: markingType,
人行横道节点: crosswalk || stopLine ? feature.get("crossing_node_id") : null,
OSM道路: feature.get("osm_way_ids"),
原生道路: feature.get("road_id"),
道路段: centerLine ? feature.get("segment_id") : null,
方向: feature.get("direction"),
车道: feature.get("lane_id") || feature.get("lane_index") || `${feature.get("left_lane_index")}${feature.get("right_lane_index")} 之间`,
转向: turnArrow ? feature.get("maneuver") : null,
样式: centerLine || edgeLine || provenance === "native-road-lane-separator/v1" ? feature.get("effective_style") : null,
放置方法: feature.get("placement_method") || null,
道路内距离米: feature.get("distance_along_lane_meters") || null,
路口前距离米: feature.get("placement_distance_meters") || null,
来源: provenance,
}, null, 2);
return message(`已选中${markingType}`);
}
const movement = state.compiled.movements?.find((item) => item.id === feature.get("movement_id")) || null;
selectRoad(roadForFeature(feature), undefined, movement);
});
map.on("pointermove", (event) => { map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel, { hitTolerance: 8 }) ? "pointer" : ""; });
function message(text) { status.textContent = text; }
function updateDirtyState() {
const count = staged.length;
dirtyState.textContent = count ? `未保存修改 ${count}` : "所有修改已保存";
dirtyState.classList.toggle("dirty", count > 0);
saveButton.disabled = count === 0;
}
function roadLabel(road) { return road.tags.name || `${road.highway}OSM ${road.osmWayIds.join(", ")}`; }
function osmDirectionLabel(road) { return road?.direction === "forward" ? "沿 OSM 方向" : "逆 OSM 方向"; }
function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.slice(5, laneId.lastIndexOf(":")) : ""; }
function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); }
function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; }
function laneStyle(feature) { const roadId = feature.get("road_id"); const selected = Boolean(roadId && selectedRoad && roadId === selectedRoad.id); const composite = feature.get("cluster_preview"); return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : composite ? 1.6 : 1.3, lineDash: composite ? [7, 5] : [5, 4] }) }); }
function markingStyle(feature) { const yellow = feature?.get("color") === "yellow"; return new Style({ fill: new Fill({ color: yellow ? "#f5be2a" : "#f5f6ee" }), stroke: new Stroke({ color: yellow ? "#d29d16" : "#d9dacf", width: 1 }) }); }
function signalAssemblyStyle(feature) { const component = feature.get("signal_component"); if (component === "mast") return [new Style({ stroke: new Stroke({ color: "#fff", width: 9 }) }), new Style({ stroke: new Stroke({ color: "#007f99", width: 5 }) })]; if (component === "face") return [new Style({ stroke: new Stroke({ color: "#fff", width: 7 }) }), new Style({ stroke: new Stroke({ color: "#df2435", width: 3 }) })]; if (component === "head") { const heading = Number(feature.get("face_heading_deg")) || 0; return new Style({ image: new RegularShape({ points: 3, radius: 8, rotation: heading * Math.PI / 180, fill: new Fill({ color: "#df2435" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); } return new Style({ image: new RegularShape({ points: 4, radius: 6, angle: Math.PI / 4, fill: new Fill({ color: "#263630" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); }
function centerLineStyle(feature) { const white = feature.get("color") === "white"; const color = white ? "#faf9ee" : "#f5be2a"; return new Style({ fill: new Fill({ color }), stroke: new Stroke({ color: feature.get("pattern") === "solid" ? color : white ? "#aeb0aa" : "#d29d16", width: feature.get("pattern") === "solid" ? .25 : .8 }) }); }
function nativeSurfaceStyle(feature) {
// Split road features meet at OSM junction nodes. Their per-feature outlines
// are editing aids, not physical seams, so scene mode must render fills only.
if (scenePreview) return new Style({ fill: new Fill({ color: "#3f4b50" }) });
if (feature.get("cluster_id") && feature.get("complex_part")) return new Style({ fill: new Fill({ color: "#6f948a" }) });
if (feature.get("kind") === "cluster") return new Style({ fill: new Fill({ color: "rgba(20, 132, 112, .5)" }), stroke: new Stroke({ color: "#075e4f", width: 4, lineDash: [10, 5] }) });
if (feature.get("template")) return new Style({ fill: new Fill({ color: "rgba(20, 132, 112, .46)" }), stroke: new Stroke({ color: "#087c67", width: 3, lineDash: [7, 4] }) });
return feature.get("native_id")?.startsWith("junction:")
? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) })
: new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) });
}
function sidewalkSurfaceStyle() {
return scenePreview
? new Style({ fill: new Fill({ color: "#b7b9ad" }) })
: new Style({ fill: new Fill({ color: "rgba(218, 191, 137, .6)" }), stroke: new Stroke({ color: "#9b7c40", width: 1 }) });
}
function directionArrowFeature(geometry) { const middle = geometry.getCoordinateAt(.5); const before = geometry.getCoordinateAt(.48); const after = geometry.getCoordinateAt(.52); const length = Math.hypot(after[0] - before[0], after[1] - before[1]); if (length < .01) return null; return new Feature({ geometry: new Point(middle), rotation: Math.atan2(after[1] - before[1], after[0] - before[0]) }); }
function refreshOsmDirection() { const directionSource = layers.osmDirection.getSource(); directionSource.clear(); if (!selectedRoad) return; const centerline = new LineString(selectedRoad.centerline).transform("EPSG:4326", "EPSG:3857"); const arrow = directionArrowFeature(centerline); if (arrow) directionSource.addFeature(arrow); }
function refreshSelectedMovement() { const movementSource = layers.selectedMovement.getSource(); movementSource.clear(); if (!selectedMovement?.geometryPublished || !effectiveConnectorEnabled({ connection_id: selectedMovement.connectionId, fromLaneId: selectedMovement.fromLaneId, toLaneId: selectedMovement.toLaneId })) return; const feature = layers.connectors.getSource().getFeatures().find((candidate) => candidate.get("movement_id") === selectedMovement.id); if (feature) movementSource.addFeature(new Feature({ geometry: feature.getGeometry().clone() })); }
function roadForFeature(feature) {
const properties = feature.getProperties();
const roadId = properties.road_id || properties.subjectId || roadIdFromLane(properties.from_lane_id) || properties.directional_road_ids?.split(",")[0];
return state.compiled.model.roads.find((road) => road.id === roadId) || null;
}
function junctionForFeature(feature) { const id = feature.get("native_id"); return id?.startsWith("junction:") || id?.startsWith("junction-cluster:") ? layers.native.getSource().getFeatures().find((item) => item.get("native_id") === id) : null; }
function endpointFor(road, side) { return state.compiled.model.endpoints.find((endpoint) => endpoint.roadId === road?.id && endpoint.side === side) || null; }
function endpointsCompatible(from, to) { if (!from || !to || from.roadId === to.roadId || from.side !== "end" || to.side !== "start") return false; if (from.nodeId === to.nodeId) return true; const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180); const dy = (from.coordinate[1] - to.coordinate[1]) * 111320; return Math.hypot(dx, dy) <= 35; }
function readFeatures(collection, predicate = null) { const source = collection || { type: "FeatureCollection", features: [] }; const filtered = predicate ? { ...source, features: (source.features || []).filter(predicate) } : source; return geojson.readFeatures(filtered, { dataProjection: "EPSG:4326", featureProjection: "EPSG:3857" }); }
function rawRoadFeatures() { return state.compiled.model.roads.map((road) => new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857"), road_id: road.id })); }
function updateSources() {
layers.reference.getSource().clear(); layers.reference.getSource().addFeatures(readFeatures(state.layers.osm2streetsRoadSurface));
layers.gaodeReference.getSource().clear(); layers.gaodeReference.getSource().addFeatures(readFeatures(state.junctionReference?.converted));
layers.native.getSource().clear(); layers.native.getSource().addFeatures([...readFeatures(state.layers.nativeRoadSurface), ...readFeatures(state.layers.nativeIntersectionSurface)]);
layers.sidewalks.getSource().clear(); layers.sidewalks.getSource().addFeatures(readFeatures(state.layers.nativeSidewalkSurface));
layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures());
layers.lanes.getSource().clear(); layers.lanes.getSource().addFeatures(readFeatures(state.layers.laneCenterlines, (feature) => !feature.properties?.cluster_internal && !feature.properties?.cluster_preview_hidden));
layers.edgeLines.getSource().clear(); layers.edgeLines.getSource().addFeatures(readFeatures(state.layers.edgeLines));
layers.directionArrows.getSource().clear(); layers.directionArrows.getSource().addFeatures(readFeatures(state.layers.directionArrows, (feature) => !feature.properties?.cluster_preview_hidden));
layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators, (feature) => !feature.properties?.cluster_preview_hidden), ...readFeatures(state.layers.turnArrows, (feature) => !feature.properties?.cluster_preview_hidden)]);
layers.centerLines.getSource().clear(); layers.centerLines.getSource().addFeatures(readFeatures(state.layers.centerLines, (feature) => !feature.properties?.cluster_preview_hidden));
layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]);
const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue;
layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors, (feature) => !feature.properties?.cluster_internal));
candidatesToggle.hidden = !(state.debug?.junctionCandidates || []).length;
renderJunctionCandidates();
layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) }));
const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
}
// Draw one convex-ish hull per candidate cluster so the whole intersection is
// outlined, not just its centre, and label it with the same index the inspector
// and the console listing use.
function renderJunctionCandidates() {
const source = layers.junctionCandidates.getSource();
source.clear();
const list = state.debug?.junctionCandidates || [];
if (!list.length) return;
for (const candidate of list) {
const nodes = candidate.nodeIds.map((nodeId) => nodeCoordinate(nodeId)).filter(Boolean);
if (!nodes.length) continue;
const ring = candidateRing(nodes, Math.max(12, candidate.coreRadiusMeters * .6));
source.addFeature(new Feature({ geometry: new Polygon([ring]), candidate_index: candidate.index, index: candidate.index, nodeCount: candidate.nodeCount, candidate_id: candidate.id }));
}
}
function nodeCoordinate(nodeId) {
for (const road of state.compiled.model.roads) {
const at = road.sourceNodeIds.findIndex((item) => String(item) === String(nodeId));
if (at === 0) return road.centerline[0];
if (at === road.sourceNodeIds.length - 1) return road.centerline.at(-1);
}
return null;
}
// A rounded envelope around the member nodes: sample a circle of `padMeters`
// around each node and take the outer boundary by angle from the centroid.
function candidateRing(nodes, padMeters) {
const centre = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]);
const metresPerLon = 111320 * Math.cos(centre[1] * Math.PI / 180);
const points = [];
for (let degrees = 0; degrees < 360; degrees += 12) {
const radians = degrees * Math.PI / 180;
let best = null;
for (const node of nodes) {
const point = [node[0] + Math.sin(radians) * padMeters / metresPerLon, node[1] + Math.cos(radians) * padMeters / 111320];
const reach = (point[0] - centre[0]) * metresPerLon * Math.sin(radians) + (point[1] - centre[1]) * 111320 * Math.cos(radians);
if (!best || reach > best.reach) best = { point, reach };
}
points.push(fromLonLat(best.point));
}
return [...points, points[0]];
}
function selectJunctionCandidate(feature) {
const candidate = (state.debug?.junctionCandidates || []).find((item) => item.index === feature.get("candidate_index"));
if (!candidate) return;
selectedRoad = null; selectedMovement = null; selectedJunction = null;
form.hidden = true; hint.hidden = false; selectedJunctionPanel.hidden = true;
hint.textContent = `复杂路口候选 #${candidate.index}${candidate.nodeCount} 个节点,直径 ${candidate.diameterMeters} 米。下方是可直接粘贴到 config 的片段。`;
evidence.textContent = JSON.stringify({
说明: "复制到区域配置文件的 nativeRoad.junctionTemplates.clusters 数组,然后重新编译",
片段: {
id: `cluster-${candidate.nodeIds[0]}`,
template: candidate.template,
nodeIds: candidate.nodeIds,
coreRadiusMeters: candidate.coreRadiusMeters,
cornerRadiusMeters: 12,
outerRadiusExtraMeters: 18,
},
实测: { 节点数: candidate.nodeCount, 直径米: candidate.diameterMeters, 最长内部连接米: candidate.longestLinkMeters, 最宽进口米: candidate.widestApproachMeters },
提示: "coreRadiusMeters 是按节点跨度估的起点,配好后按实际效果调整;有高德参考几何时它会被校准值覆盖。",
}, null, 2);
renderCandidateAction(candidate);
message(`已选中复杂路口候选 #${candidate.index}`);
}
// The accept button lives beside the snippet so the manual path stays available
// if the write is refused; both describe the same cluster.
function renderCandidateAction(candidate) {
candidateAction.replaceChildren();
const button = document.createElement("button");
button.type = "button";
button.textContent = `把候选 #${candidate.index} 加入配置并重新编译`;
button.onclick = async () => {
button.disabled = true;
message(`正在把候选 #${candidate.index} 写入区域配置...`);
try {
const response = await fetch("/api/junction-clusters", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: candidate.index }), cache: "no-store" });
const result = await response.json();
if (!response.ok || result.ok === false) throw new Error(result.error || `HTTP ${response.status}`);
state = result;
staged = [];
updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary();
candidateAction.replaceChildren();
hint.textContent = `已加入复杂路口 ${result.added.id}${result.added.nodeIds.length} 个节点),配置已更新并重新编译。`;
message(`已加入 ${result.added.id};如需撤销可 git checkout 区域配置`);
} catch (error) {
button.disabled = false;
message(`加入失败:${error.message}`);
}
};
candidateAction.append(button);
}
function laneId(connection, side) { return connection[side === "from" ? "fromLaneId" : "toLaneId"] || connection[side === "from" ? "from_lane_id" : "to_lane_id"]; }
function effectiveLaneEnabled(connector) { const id = `车道连接:${laneId(connector, "from")}->${laneId(connector, "to")}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; }
function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; }
function effectiveConnectorEnabled(connector) { const connection = state?.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); }
function selectRoad(road, note, movement = null) {
candidateAction.replaceChildren();
selectedRoad = road; selectedMovement = movement; selectedJunction = null; selectedJunctionPanel.hidden = true; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection(); refreshSelectedMovement();
layers.selectedRoad.getSource().clear(); if (road) layers.selectedRoad.getSource().addFeature(new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857") }));
form.hidden = !road; hint.hidden = Boolean(road); if (!road) return;
roadName.textContent = `${roadLabel(road)}${osmDirectionLabel(road)}`;
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 当前方向节点顺序: road.sourceNodeIds, 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
laneConvention.textContent = road.laneCount === 1 ? "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。本方向只有一条车道。" : "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。车道按行驶方向从左向右编号。";
renderDirectionSwitch(road); renderMovementSummary(road); renderSelectedMovement(); renderConnections(road); message(note || (selectedMovement ? `已选中行驶动作:${turnLabel(selectedMovement.turn)}` : `已选中:${roadLabel(road)}`));
}
function selectJunction(feature) {
candidateAction.replaceChildren();
selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false;
const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean);
const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean);
junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id || properties.osm_node_ids, 类型: properties.kind === "cluster" ? "复合路口簇" : properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 模板: properties.template, 边界策略: properties.boundary_mode, 基础截面面积平方米: properties.approach_area_m2, 最终路口面积平方米: properties.surface_area_m2, 外缘扩张倍率: properties.expansion_ratio, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2);
message(`已选中路口OSM 节点 ${properties.osm_node_id || properties.osm_node_ids}`);
}
function selectSignal(feature) {
selectedSignal = feature.get("signal_uid"); const p = feature.getProperties(); signalPicker.value = selectedSignal;
const [x, y] = feature.getGeometry().getCoordinates();
map.getView().fit([x - 25, y - 25, x + 25, y + 25], { padding: [80, 80, 80, 360], maxZoom: 22, duration: 250 });
signalForm.hidden = false; signalOutput.textContent = `${p.display_id || p.signal_uid}${p.signal_uid}`;
signalForm.lon.value = feature.getGeometry().clone().transform("EPSG:3857", "EPSG:4326").getCoordinates()[0];
signalForm.lat.value = feature.getGeometry().clone().transform("EPSG:3857", "EPSG:4326").getCoordinates()[1];
signalForm.mastHeading.value = p.mast_heading_deg; signalForm.mastReach.value = p.mast_reach_m; signalForm.faceHeading.value = p.face_heading_deg; signalForm.phase.value = p.phase_group; signalForm.enabled.checked = p.enabled;
evidence.textContent = JSON.stringify({ 信号灯: p.signal_uid, 控制节点: p.control_id, 路口方向: p.approach_id, 来源: state.trafficSignals.provenance }, null, 2); message("已选中原生红绿灯");
}
function poleFeatureForSignal(signalUid) { return layers.signals.getSource().getFeatures().find((item) => item.get("signal_uid") === signalUid && !item.get("signal_component")); }
async function saveSignals(document) { const response = await fetch("/api/traffic-signals", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(document) }); const result = await response.json(); if (!result.ok) throw new Error(result.error); state.trafficSignals = result.trafficSignals; state.trafficRuntime = result.runtime; updateSources(); renderSummary(); }
function signalDocumentWithChange(change) { const document = structuredClone(state.trafficSignals); document.assemblies.features = change(document.assemblies.features); return document; }
signalForm.onsubmit = async (event) => { event.preventDefault(); try { await saveSignals(signalDocumentWithChange((features) => features.map((feature) => feature.properties.signal_uid !== selectedSignal ? feature : { ...feature, geometry: { type: "Point", coordinates: [Number(signalForm.lon.value), Number(signalForm.lat.value)] }, properties: { ...feature.properties, mast_heading_deg: Number(signalForm.mastHeading.value), mast_reach_m: Number(signalForm.mastReach.value), face_heading_deg: Number(signalForm.faceHeading.value), phase_group: Number(signalForm.phase.value), enabled: signalForm.enabled.checked } }))); message("红绿灯已保存"); } catch (error) { message(error.message); } };
signalPanel.querySelector('[data-signal="delete"]').onclick = async () => { try { await saveSignals(signalDocumentWithChange((features) => features.filter((feature) => feature.properties.signal_uid !== selectedSignal))); signalForm.hidden = true; selectedSignal = null; message("红绿灯已删除"); } catch (error) { message(error.message); } };
signalPanel.querySelector('[data-signal="generate"]').onclick = async () => { try { const response = await fetch("/api/traffic-signals/generate", { method: "POST" }); const result = await response.json(); if (!result.ok) throw new Error(result.error); state.trafficSignals = result.trafficSignals; state.trafficRuntime = result.runtime; updateSources(); message("已补充 OSM 信号灯"); } catch (error) { message(error.message); } };
signalPicker.onchange = () => { const feature = poleFeatureForSignal(signalPicker.value); if (feature) selectSignal(feature); };
function turnLabel(turn) { return { left: "左转", through: "直行", right: "右转", uturn: "掉头" }[turn] || turn; }
function renderSelectedMovement() { selectedMovementPanel.hidden = !selectedMovement; if (!selectedMovement) return; const targetRoad = state.compiled.model.roads.find((road) => road.id === selectedMovement.toRoadId); const geometry = selectedMovement.geometryStatus === "connector" ? "已绘制路径" : selectedMovement.geometryStatus === "continuous" ? "节点连续" : "路径过长未绘制"; movementDetail.textContent = `${turnLabel(selectedMovement.turn)}${lanePositionLabel(selectedRoad, laneIndex(selectedMovement.fromLaneId))}${lanePositionLabel(targetRoad, laneIndex(selectedMovement.toLaneId))}\n目标:${roadLabel(targetRoad)}${osmDirectionLabel(targetRoad)}\n来源端点:${selectedRoad.sourceNodeIds.at(-1)};目标端点:${targetRoad.sourceNodeIds[0]}\n路口节点:${selectedMovement.nodeId}\n状态:${geometry}\n来源:${selectedMovement.provenance}`; }
function renderDirectionSwitch(road) {
directionSwitch.innerHTML = ""; const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
if (alternatives.length < 2) { directionSwitch.textContent = "单向道路"; return; }
for (const item of alternatives) { const button = document.createElement("button"); button.type = "button"; button.textContent = osmDirectionLabel(item); button.disabled = item.id === road.id; button.onclick = () => selectRoad(item); directionSwitch.append(button); }
}
function renderMovementSummary(road) { const movements = state.compiled.movements?.filter((movement) => movement.fromRoadId === road.id && effectiveConnectorEnabled({ connection_id: movement.connectionId, fromLaneId: movement.fromLaneId, toLaneId: movement.toLaneId })) || []; const turns = movements.reduce((result, item) => { result[item.turn] = (result[item.turn] || 0) + 1; return result; }, {}); const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; const published = movements.filter((movement) => movement.geometryPublished).length; movementSummary.textContent = movements.length ? `已识别 ${movements.length} 个行驶动作,${published} 条已绘制路径:${Object.entries(turns).map(([key, value]) => `${labels[key] || key} ${value}`).join("")}` : "当前方向没有已识别的行驶动作"; }
function renderConnections(road) {
connectionsBox.innerHTML = ""; const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end"); addConnectionButton.hidden = !endpoint; const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
if (!rows.length) connectionsBox.textContent = "当前方向到达终点后没有已识别的驶出道路。";
for (const connection of rows) { const target = state.compiled.model.roads.find((item) => item.id === state.compiled.model.endpoints.find((endpointItem) => endpointItem.id === connection.toEndpointId)?.roadId); if (!target) continue; const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveConnectionEnabled(connection); input.onchange = () => { stageConnection(connection, input.checked); selectRoad(road, "有未保存修改:转向路径已即时更新"); }; label.append(input, ` ${turnName(road, target)}${roadLabel(target)}${osmDirectionLabel(target)}`); connectionsBox.append(label); renderLaneControls(connection); }
renderManualCandidates(endpoint, road);
}
function renderManualCandidates(endpoint, road) { const candidates = state.compiled.diagnostics.find((item) => item.endpointId === endpoint?.id)?.manualCandidates || []; if (!candidates.length) return; const title = document.createElement("p"); title.textContent = "附近可手工连接的驶出方向"; connectionsBox.append(title); for (const candidate of candidates) { const target = state.compiled.model.roads.find((item) => item.id === candidate.roadId); if (!target) continue; const button = document.createElement("button"); button.type = "button"; button.textContent = `${roadLabel(target)}${candidate.distanceMeters} 米)`; button.onclick = () => { stageConnection({ id: `connection:${endpoint.id}:${candidate.toEndpointId}`, fromEndpointId: endpoint.id, toEndpointId: candidate.toEndpointId }, true); selectRoad(road, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }; connectionsBox.append(button); } }
function renderLaneControls(connection) { const rows = state.compiled.movements?.filter((movement) => movement.connectionId === connection.id) || []; for (const row of rows) { const targetRoad = state.compiled.model.roads.find((road) => road.id === row.toRoadId); const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); selectRoad(selectedRoad, "有未保存修改:转向路径已即时更新"); }; const geometryNote = row.geometryStatus === "continuous" ? ",节点连续" : row.geometryStatus === "deferred-too-long" ? ",路径过长未绘制" : ""; label.append(input, ` ${lanePositionLabel(selectedRoad, laneIndex(row.fromLaneId))}${lanePositionLabel(targetRoad, laneIndex(row.toLaneId))}${osmDirectionLabel(targetRoad)}${geometryNote}`); connectionsBox.append(label); } }
function turnName(from, to) { const heading = (a, b) => Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; const delta = ((heading(to.centerline[0], to.centerline[1]) - heading(from.centerline.at(-2), from.centerline.at(-1)) + 540) % 360) - 180; return Math.abs(delta) >= 150 ? "掉头" : Math.abs(delta) <= 30 ? "直行" : delta > 0 ? "右转" : "左转"; }
function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); layers.connectors.changed(); updateDirtyState(); }
function stageLaneConnection(connector, enabled) { const fromLaneId = laneId(connector, "from"); const toLaneId = laneId(connector, "to"); const id = `车道连接:${fromLaneId}->${toLaneId}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId, toLaneId, enabled }); layers.connectors.changed(); updateDirtyState(); }
function chooseManualTarget(targetRoad) { const toEndpoint = endpointFor(targetRoad, "start"); if (!endpointsCompatible(manualFromEndpoint, toEndpoint)) return message("该方向的起点与当前道路终点不兼容:必须是同一路口,或相距不超过 35 米。"); const connection = { id: `connection:${manualFromEndpoint.id}:${toEndpoint.id}`, fromEndpointId: manualFromEndpoint.id, toEndpointId: toEndpoint.id }; manualFromEndpoint = null; stageConnection(connection, true); selectRoad(selectedRoad, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }
addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad, "end"); if (!endpoint) return; manualFromEndpoint = endpoint; select.getFeatures().clear(); message("请在地图上点击目标方向的 OSM 中心线;仅同一路口或 35 米内的驶出方向可连接。"); };
function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); const junction = layers.native.getSource().getFeatures().find((candidate) => candidate.get("native_id") === item.subjectId); if (junction) return selectJunction(junction); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); }
function diagnosticLabel(item) { const road = state.compiled.model.roads.find((candidate) => candidate.id === item.subjectId); if (item.rule !== "unconnected-interior-road-end" || !road) return item.message; const candidateCount = item.manualCandidates?.length || 0; return `${roadLabel(road)}${osmDirectionLabel(road)},节点 ${item.sourceIds[0]}):内部端点未连接${candidateCount ? `,附近有 ${candidateCount} 个可手工连接候选` : ""}`; }
function renderDiagnostics() { const all = state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface"); const counts = { all: all.length, candidates: all.filter((item) => item.manualCandidates?.length).length, other: all.filter((item) => !item.manualCandidates?.length).length }; for (const button of diagnosticFilters.querySelectorAll("button")) { const filter = button.dataset.diagnosticFilter; button.classList.toggle("active", filter === diagnosticFilter); button.textContent = `${filter === "all" ? "全部" : filter === "candidates" ? "可连接" : "其他"}${counts[filter]}`; } const visible = all.filter((item) => diagnosticFilter === "all" || diagnosticFilter === "candidates" ? Boolean(item.manualCandidates?.length) : !item.manualCandidates?.length).sort((a, b) => (b.manualCandidates?.length || 0) - (a.manualCandidates?.length || 0)); diagnostics.innerHTML = ""; for (const item of visible) { const button = document.createElement("button"); button.textContent = diagnosticLabel(item); button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } }
function renderSummary() {
const comparison = state.comparison;
const rows = [
["方向道路", comparison.nativeRoadCount],
["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures],
["路口面", comparison.nativeJunctionSurfaceFeatures],
["普通构面路口", comparison.nativeApproachEnvelopeJunctions],
["兜底构面路口", comparison.nativeFallbackJunctions],
["最大外缘扩张", comparison.nativeMaxJunctionExpansionRatio],
["道路中心虚线", comparison.nativeCenterLineFeatures],
["道路方向箭头", comparison.nativeDirectionArrowFeatures],
["路口转向箭头", comparison.nativeTurnArrowFeatures],
["斑马线条带", comparison.nativeCrosswalkFeatures],
["停止线", comparison.nativeVehicleStopLineFeatures],
["红绿灯设施", state.trafficSignals?.assemblies?.features?.length || 0],
["行驶动作", comparison.nativeMovementCount],
["已绘制路径", comparison.nativePublishedMovementCount],
["可手工复核", comparison.unconnectedEndsWithManualCandidates],
["内部断头", comparison.unconnectedInteriorRoadEnds],
["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"],
];
summary.innerHTML = "";
for (const [label, value] of rows) {
const term = document.createElement("dt"); const detail = document.createElement("dd");
term.textContent = label; detail.textContent = value; summary.append(term, detail);
}
}
function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); }
function setMarkingStyleForm(title, target, style, allowsDouble) { centerLineForm.hidden = false; markingStyleHeading.textContent = title; centerLineSegment.textContent = target; doubleYellowOption.hidden = !allowsDouble; centerLineStyleInput.value = style || "yellow-dashed"; }
function selectCenterLine(feature) { selectedCenterLineSegment = feature.get("segment_id"); selectedLaneSeparator = null; setMarkingStyleForm("道路中心线样式", `道路段:${selectedCenterLineSegment}`, feature.get("effective_style"), true); }
function selectLaneSeparator(feature) { selectedCenterLineSegment = null; selectedLaneSeparator = feature.getProperties(); setMarkingStyleForm("车道分隔线样式", `${selectedLaneSeparator.left_lane_index} 与第 ${selectedLaneSeparator.right_lane_index} 车道之间`, selectedLaneSeparator.effective_style || "white-dashed", false); }
function selectEdgeLine(feature) { selectedCenterLineSegment = null; selectedLaneSeparator = null; selectedEdgeLine = feature.getProperties(); setMarkingStyleForm("道路外缘线样式", `${selectedEdgeLine.side === "left" ? "左" : "右"}侧外缘`, selectedEdgeLine.effective_style || "white-solid", false); }
function clearCenterLineSelection() { selectedCenterLineSegment = null; selectedLaneSeparator = null; selectedEdgeLine = null; centerLineForm.hidden = true; }
function stageCenterLineStyle(segmentId, style) { const parts = style.split("-"); const double = parts[0] === "double"; const [color, pattern] = double ? parts.slice(1) : parts; const id = `道路中心线:${segmentId}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "center-line-style", segmentId, color, pattern, double }); }
form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); };
function stageSelectedCenterLineStyle() { if (!selectedCenterLineSegment && !selectedLaneSeparator && !selectedEdgeLine) return; if (selectedCenterLineSegment) stageCenterLineStyle(selectedCenterLineSegment, centerLineStyleInput.value); else { const [color, pattern] = centerLineStyleInput.value.split("-"); const item = selectedLaneSeparator || selectedEdgeLine; const id = selectedLaneSeparator ? `车道分隔线:${item.road_id}:${item.left_lane_index}-${item.right_lane_index}` : `道路外缘线:${item.road_id}:${item.side}`; staged = staged.filter((change) => change.id !== id); staged.push(selectedLaneSeparator ? { id, kind: "lane-separator-style", roadId: item.road_id, leftLaneIndex: item.left_lane_index, rightLaneIndex: item.right_lane_index, color, pattern } : { id, kind: "edge-line-style", roadId: item.road_id, side: item.side, color, pattern }); } updateDirtyState(); message("有未保存修改:线样式"); }
centerLineForm.onsubmit = (event) => { event.preventDefault(); stageSelectedCenterLineStyle(); };
centerLineStyleInput.onchange = stageSelectedCenterLineStyle;
async function saveStagedChanges() { if (!staged.length) return true; const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; }
saveButton.onclick = async () => { if (await saveStagedChanges()) message("已保存,点击“保存并重新生成”写入几何"); };
compileButton.onclick = async () => {
if (!await saveStagedChanges()) return;
message("正在保存修改并重新生成...");
const response = await fetch("/api/compile", { method: "POST", cache: "no-store" });
const nextState = await response.json();
if (!response.ok || nextState.ok === false || !nextState.compiled?.model || !nextState.layers) {
message(`重新生成失败:${nextState.error || `HTTP ${response.status}`}`);
return;
}
state = nextState;
staged = [];
updateDirtyState();
updateSources();
renderDiagnostics();
renderSummary();
selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null);
message("已保存并重新生成");
};
for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { const visible = input.checked; layers[input.dataset.layer].setVisible(visible); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(visible); };
scenePreviewToggle.onchange = () => {
scenePreview = scenePreviewToggle.checked;
for (const input of document.querySelectorAll("[data-layer]")) {
const layer = input.dataset.layer;
if (["osm", "lanes", "reference", "gaodeReference"].includes(layer)) layers[layer].setVisible(!scenePreview && input.checked);
}
layers.osmDirection.setVisible(!scenePreview && document.querySelector('[data-layer="osm"]').checked);
layers.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked);
layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked);
layers.centerLines.setVisible(document.querySelector('[data-layer="centerLines"]').checked);
layers.controls.setVisible(document.querySelector('[data-layer="controls"]').checked);
const signalsVisible = document.querySelector('[data-layer="signals"]').checked;
layers.signals.setVisible(signalsVisible);
layers.diagnostics.setVisible(!scenePreview);
layers.native.changed(); layers.sidewalks.changed();
message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览");
};
for (const button of diagnosticFilters.querySelectorAll("button")) button.onclick = () => { diagnosticFilter = button.dataset.diagnosticFilter; renderDiagnostics(); };
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; const signalUid = new URLSearchParams(location.search).get("signal"); const signal = signalUid && poleFeatureForSignal(signalUid); if (signal) selectSignal(signal); message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));

View File

@@ -1,4 +0,0 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/vendor/ol/ol.css"><link rel="stylesheet" href="/app.css"></head>
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><span id="dirty-state" aria-live="polite"></span><label style="display:inline;margin:0 0 0 auto;white-space:nowrap"><input id="scene-preview" type="checkbox"> 场景效果</label><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
<main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="sidewalks" type="checkbox" checked> 路缘与步行带</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="gaodeReference" type="checkbox" checked> 高德规整路口参考</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道、转向路径或路口面以查看详情。</p><section id="selected-junction" hidden><h2>当前路口</h2><output id="junction-detail"></output></section><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.01"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有路缘与步行带</label><label><input id="right" type="checkbox"> 右侧有路缘与步行带</label><button type="submit">暂存本道路修改</button></form><form id="center-line-form" hidden><h2 id="marking-style-heading">道路中心线样式</h2><output id="center-line-segment"></output><label>样式<select id="center-line-style"><option value="yellow-dashed">黄色虚线(默认)</option><option value="white-dashed">白色虚线</option><option value="yellow-solid">黄色实线</option><option value="white-solid">白色实线</option></select></label><button type="submit">暂存标线样式</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence"></pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html>

View File

@@ -1,152 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const http = require("http");
const path = require("path");
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../src/compile/native-road");
const { generate, validateDocument, runtime } = require("../src/native-traffic-signals");
const { convertGeoJson } = require("../src/reference/gaode");
function startWorkbench({ area, configPath, repoRoot, compileFresh, readAreaConfig, junctionReference = null, debug = false, port = 8787 }) {
if (typeof junctionReference === "string") junctionReference = readJunctionReference(junctionReference);
// `--debug` surfaces advisory compiler findings that have no geometry layer of
// their own — currently the complex-junction candidates. Off by default so the
// normal editing view stays uncluttered.
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
const context = { repoRoot, configPath, compileFresh, readAreaConfig };
const server = http.createServer((request, response) => handle(request, response, area, context, junctionReference, debug));
server.on("error", (error) => {
console.error(`Road Workbench failed to listen: ${error.message}`);
process.exitCode = 1;
});
server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? " (debug: 复杂路口候选已开启)" : ""}`));
return server;
}
function handle(request, response, area, context, junctionReference, debug = false) {
const url = new URL(request.url, "http://127.0.0.1");
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(__dirname, "client", "index.html"), "text/html; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(__dirname, "client", "app.js"), "text/javascript; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(__dirname, "client", "app.css"), "text/css; charset=utf-8");
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname, context.repoRoot);
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area, junctionReference, debug));
if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => {
const document = validateDocument(body, fs.readFileSync(area.input, "utf8"));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/traffic-signals/generate") return Promise.resolve().then(() => {
const compiled = readCompiled(area);
const generated = generate(fs.readFileSync(area.input, "utf8"), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "vehicle_stop_lines.geojson")), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "intersection_surface.geojson")));
const current = validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"));
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
current.assemblies.features.push(...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid)));
writeJsonAtomic(area.outputs.nativeTrafficSignals, current);
sendJson(response, 200, { ok: true, trafficSignals: current, runtime: runtime(current), generated: generated.assemblies.features.length, compiled: Boolean(compiled) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
const compiled = readCompiled(area);
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
sendJson(response, 200, { ok: true, overrides });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/junction-clusters") return readBody(request).then((body) => {
if (!debug) throw new Error("该接口仅在 --debug 模式下可用。");
const added = addJunctionCluster(context.configPath, body, readCompiled(area), context.readAreaConfig, context.repoRoot);
context.compileFresh();
const refreshed = context.readAreaConfig(context.configPath, { repoRoot: context.repoRoot });
sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
context.compileFresh();
sendJson(response, 200, state(area, junctionReference));
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
sendJson(response, 404, { error: "Not found" });
}
function state(area, junctionReference = null, debug = false) {
const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals)
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"))
: { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } };
const trafficRuntime = runtime(trafficSignals);
const compiled = readCompiled(area);
return { areaId: area.id, debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null, compiled, overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), junctionReference, layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
}
// The compiler reports candidates as advisory diagnostics. Lift them into their
// own payload with a stable index so the map can label them "#1, #2, ..." and
// the inspector can offer a ready-to-paste cluster配置.
// Append one detected cluster to the hand-authored area config. The candidate
// must still be present in the latest compile, so a stale browser tab cannot
// write a cluster that no longer exists. The edited config is validated by the
// real loader before it replaces the file: an invalid write would break every
// later command, and the file is git-tracked so a bad accept stays revertible.
function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot) {
const index = Number(body?.index);
if (!Number.isInteger(index)) throw new Error("请求缺少候选编号 index。");
const candidate = junctionCandidates(compiled).find((item) => item.index === index);
if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`);
const raw = readJson(configPath);
const templates = raw.nativeRoad?.junctionTemplates;
if (!templates) throw new Error("区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。");
const clusters = Array.isArray(templates.clusters) ? templates.clusters : [];
const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String)));
const clash = candidate.nodeIds.filter((nodeId) => taken.has(String(nodeId)));
if (clash.length) throw new Error(`节点 ${clash.join("、")} 已属于其他复杂路口配置。`);
const id = uniqueClusterId(`cluster-${candidate.nodeIds[0]}`, new Set(clusters.map((cluster) => cluster.id)));
const cluster = {
id,
template: candidate.template,
coreRadiusMeters: candidate.coreRadiusMeters,
cornerRadiusMeters: 12,
outerRadiusExtraMeters: 18,
nodeIds: candidate.nodeIds.map(String),
};
const next = { ...raw, nativeRoad: { ...raw.nativeRoad, junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] } } };
const staging = `${configPath}.candidate-${process.pid}.json`;
fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`);
try {
readAreaConfig(staging, { repoRoot });
} catch (error) {
fs.unlinkSync(staging);
throw new Error(`写入后的配置无法通过校验,已放弃:${error.message}`);
}
fs.unlinkSync(staging);
writeJsonAtomic(configPath, next);
return cluster;
}
function uniqueClusterId(base, taken) {
if (!taken.has(base)) return base;
for (let suffix = 2; suffix < 100; suffix += 1) if (!taken.has(`${base}-${suffix}`)) return `${base}-${suffix}`;
throw new Error("无法生成唯一的 cluster id。");
}
function junctionCandidates(compiled) {
return (compiled?.diagnostics || [])
.filter((item) => item.rule === "complex-junction-candidate" && item.suggestedCluster)
.sort((first, second) => second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount || first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters)
.map((item, index) => ({ index: index + 1, id: item.id, message: item.message, coordinate: item.geometry?.coordinates || null, ...item.suggestedCluster }));
}
function readJunctionReference(file) {
if (!fs.existsSync(file)) throw new Error(`Junction reference not found: ${file}`);
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, "utf8")));
return { source: file, coordinateSystem: "GCJ-02", converted };
}
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
function readLayer(file) { return fs.existsSync(file) ? readJson(file) : { type: "FeatureCollection", features: [] }; }
function readBody(request) { return new Promise((resolve, reject) => { let body = ""; request.setEncoding("utf8"); request.on("data", (part) => { body += part; if (body.length > 1024 * 1024) request.destroy(); }); request.on("end", () => { try { resolve(JSON.parse(body)); } catch (_) { reject(new Error("Request body must be JSON.")); } }); request.on("error", reject); }); }
function sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); }
function sendVendorFile(response, pathname, repoRoot) {
const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname);
if (!match) return sendJson(response, 404, { error: "Not found" });
const root = path.join(repoRoot, "node_modules", match[1]);
const file = path.resolve(root, match[2]);
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return sendJson(response, 404, { error: "Not found" });
return sendFile(response, file, file.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
}
function sendJson(response, status, value) { response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); response.end(`${JSON.stringify(value)}\n`); }
module.exports = { startWorkbench };

View File

@@ -4,7 +4,7 @@ const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config");
const { compileArea: compileNativeRoads } = require("./compile-native-roads");
const { compileArea: compileNativeRoads } = require("./lib/road-compiler-cli");
const { resolveStages } = require("./lib/build-stages");
const { validateManifest, addIntegrity } = require("./lib/package-contract");
const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths");
@@ -226,7 +226,7 @@ function buildBlenderScene(area, roadProvider) {
fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true });
if (roadProvider === "native") {
compileNativeRoads(configPath);
compileNativeRoads(area);
ensureNativeRoadLayers(area);
}

View File

@@ -6,7 +6,7 @@ const os = require("os");
const { execFileSync } = require("child_process");
const { JsStreetNetwork } = require("osm2streets-js-node");
const { qgisPaths } = require("./lib/tool-paths");
const { buildCustomTurnLaneArrows } = require("../packages/road-compiler/src/compile/turn-lane-arrows");
const { turnLaneArrows: { buildCustomTurnLaneArrows } } = require("@osm-asset/road-compiler");
const { readTrafficSignalFeatures } = require("./lib/traffic-signals");
const {
SCENE_LAYERS,

View File

@@ -4,14 +4,14 @@
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { compileArea, parseArgs } = require("./compile-native-roads");
const { checkOutput } = require("../packages/road-compiler/src/check");
const { check } = require("@osm-asset/road-compiler");
const repoRoot = path.resolve(__dirname, "..");
function checkArea(configPath, options = {}) {
if (options.compile) compileArea(configPath);
const area = readAreaConfig(configPath, { repoRoot });
return checkOutput({ areaId: area.id, outDir: area.outputs.nativeRoadDir });
return check.checkOutput({ areaId: area.id, outDir: area.outputs.nativeRoadDir });
}
function main() {

View File

@@ -2,8 +2,8 @@
"use strict";
const path = require("path");
const { readAreaConfig, toRoadCompilerInput } = require("./lib/area-config");
const { compileInput } = require("../packages/road-compiler/src/compile/compiler");
const { readAreaConfig } = require("./lib/area-config");
const { compileArea: compileWithCli } = require("./lib/road-compiler-cli");
const repoRoot = path.resolve(__dirname, "..");
@@ -19,15 +19,13 @@ function parseArgs(argv) {
function compileArea(configPath) {
const area = readAreaConfig(configPath, { repoRoot });
const compiled = compileInput(toRoadCompilerInput(area));
return { ...compiled, area };
return { area, marker: compileWithCli(area) };
}
function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
const { area, result, comparison } = compileArea(configPath);
console.log(`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: area.id, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: area.outputs.nativeRoadDir, comparison })}`);
compileArea(configPath);
}
if (require.main === module) main();

View File

@@ -3,7 +3,7 @@
const fs = require("fs");
const path = require("path");
const { inspectReference, localReferenceSvg } = require("../packages/road-compiler/src/reference/gaode");
const { gaodeReference: { inspectReference, localReferenceSvg } } = require("@osm-asset/road-compiler");
function parseArgs(argv) {
const result = {};

View File

@@ -0,0 +1,47 @@
"use strict";
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { toRoadCompilerInput } = require("./area-config");
function compilerCli() {
const packageFile = require.resolve("@osm-asset/road-compiler/package.json");
const packageRoot = path.dirname(packageFile);
const manifest = require(packageFile);
return path.join(packageRoot, typeof manifest.bin === "string" ? manifest.bin : manifest.bin["road-compiler"]);
}
function writeInput(area) {
const input = toRoadCompilerInput(area);
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
const file = path.join(area.outputs.pipelineDir, "road-compiler.input.json");
fs.writeFileSync(file, `${JSON.stringify(input, null, 2)}\n`);
return { file, input };
}
function parseCompletionMarker(stdout, input) {
const lines = String(stdout).split(/\r?\n/).filter((line) => line.startsWith("NATIVE_ROAD_COMPILE_DONE "));
if (lines.length !== 1) throw new Error(`Expected exactly one NATIVE_ROAD_COMPILE_DONE marker, found ${lines.length}`);
let marker;
try { marker = JSON.parse(lines[0].slice("NATIVE_ROAD_COMPILE_DONE ".length)); }
catch (error) { throw new Error(`Invalid NATIVE_ROAD_COMPILE_DONE JSON: ${error.message}`); }
if (!marker || typeof marker !== "object") throw new Error("NATIVE_ROAD_COMPILE_DONE payload must be an object");
if (marker.areaId !== input.areaId) throw new Error(`NATIVE_ROAD_COMPILE_DONE areaId mismatch: expected ${input.areaId}, got ${marker.areaId}`);
if (path.resolve(marker.output || "") !== path.resolve(input.outDir)) throw new Error(`NATIVE_ROAD_COMPILE_DONE output mismatch: expected ${input.outDir}, got ${marker.output}`);
return marker;
}
function compileArea(area) {
const { file, input } = writeInput(area);
const result = spawnSync(process.execPath, [compilerCli(), "--input", file], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
if (result.stdout) process.stdout.write(result.stdout);
if (result.error) throw result.error;
if (result.status !== 0) {
const signal = result.signal ? ` signal=${result.signal}` : "";
throw new Error(`Native road compiler failed with status=${result.status}${signal}`);
}
return parseCompletionMarker(result.stdout, input);
}
module.exports = { compileArea, compilerCli, parseCompletionMarker, writeInput };

View File

@@ -1,8 +1,7 @@
"use strict";
const fs = require("fs");
const { parseOsm } = require("../../packages/road-compiler/src/osm");
const trafficSignals = require("../../packages/road-compiler/src/traffic-signals");
const { osm: { parseOsm }, trafficSignals } = require("@osm-asset/road-compiler");
function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) {
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;

View File

@@ -1,8 +1,7 @@
"use strict";
const fs = require("fs");
const { parseOsm } = require("../../packages/road-compiler/src/osm");
const {
const { osm: { parseOsm }, laneGeometry: {
appendCoordinates,
haversineMeters,
laneCenterline,
@@ -10,7 +9,7 @@ const {
orientPolyline,
polylineLength,
polylineMidpoint,
} = require("../../packages/road-compiler/src/geometry/lane-geometry");
} } = require("@osm-asset/road-compiler");
const MAX_ROUTES = 5;
const MAX_PATH_EDGES = 7;

View File

@@ -17,7 +17,7 @@ const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const { qgisPaths } = require("./lib/tool-paths");
const { parseOsm } = require("../packages/road-compiler/src/osm");
const { osm: { parseOsm } } = require("@osm-asset/road-compiler");
const {
validateTrafficSignalSourceReferences,
} = require("./lib/traffic-signals");

View File

@@ -3,7 +3,7 @@
const fs = require("fs");
const path = require("path");
const { templateFor } = require("../packages/road-compiler/src/compile/turn-lane-arrows");
const { turnLaneArrows: { templateFor } } = require("@osm-asset/road-compiler");
const output = path.resolve(process.argv[2] || path.join("outputs", "turn-lane-arrow-samples.svg"));
const DISPLAY_SCALE = 60;

View File

@@ -5,7 +5,7 @@ const { execFileSync } = require("child_process");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { parseArgs } = require("./compile-native-roads");
const { startWorkbench } = require("../packages/road-compiler/workbench/server");
const { startWorkbench } = require("@osm-asset/road-compiler/workbench/server");
const repoRoot = path.resolve(__dirname, "..");
const args = parseArgs(process.argv.slice(2));

View File

@@ -5,7 +5,7 @@ const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { gcj02ToWgs84, convertGeoJson, inspectReference } = require("../packages/road-compiler/src/reference/gaode");
const { gaodeReference: { gcj02ToWgs84, convertGeoJson, inspectReference } } = require("@osm-asset/road-compiler");
const converted = gcj02ToWgs84([114.12864875054062, 30.460485279762146]);
assert.ok(Math.abs(converted[0] - 114.1229659) < 0.00001);

View File

@@ -5,7 +5,7 @@ const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { compileRoadModel, compileGeometry, validateOverrides } = require("../packages/road-compiler/src/compile/native-road");
const { nativeRoad: { compileRoadModel, compileGeometry, validateOverrides } } = require("@osm-asset/road-compiler");
const { compileArea } = require("./compile-native-roads");
const { checkArea } = require("./check-native-roads");
@@ -164,7 +164,7 @@ assert.equal(clusterApproaches.filter((feature) => feature.properties.kind === "
assert.ok(clusterApproachGeometry.diagnostics.some((item) => item.rule === "complex-junction-insufficient-nodes"));
assert.equal(clusterApproachGeometry.intersectionSurface.features.length, 0);
const fixtureDir = path.join(__dirname, "..", "packages", "road-compiler", "test", "fixtures");
const fixtureDir = path.join(path.dirname(require.resolve("@osm-asset/road-compiler/package.json")), "test", "fixtures");
const fengshuOsm = fs.readFileSync(path.join(fixtureDir, "fengshu-er-road.osm"), "utf8");
const fengshuModel = compileRoadModel(fengshuOsm, empty);
const fengshuCluster = {
@@ -402,20 +402,22 @@ try {
fs.writeFileSync(input, osm);
fs.writeFileSync(config, JSON.stringify({ id: "fresh", input, outputRoot }));
const compiledArea = compileArea(config);
assert.equal(compiledArea.result.areaId, "fresh");
assert.equal(compiledArea.marker.areaId, "fresh");
assert.ok(fs.existsSync(path.join(outputRoot, "fresh", "native-road", "compiled.json")));
const compiled = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "compiled.json"), "utf8"));
const comparison = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "comparison.json"), "utf8"));
const centerLineLayer = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "layers", "center_lines.geojson"), "utf8"));
const edgeLineLayer = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "layers", "edge_lines.geojson"), "utf8"));
assert.equal(centerLineLayer.type, "FeatureCollection");
assert.equal(centerLineLayer.features.length, compiledArea.comparison.nativeCenterLineFeatures);
assert.equal(centerLineLayer.features.length, comparison.nativeCenterLineFeatures);
assert.equal(edgeLineLayer.features.length, 0);
assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2");
assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length);
assert.equal(compiledArea.comparison.nativePublishedMovementCount, compiledArea.result.movements.filter((movement) => movement.geometryPublished).length);
assert.equal(compiledArea.comparison.nativeCrosswalkFeatures, 0);
assert.equal(compiledArea.comparison.nativeVehicleStopLineFeatures, 0);
assert.equal(compiledArea.comparison.nativeApproachEnvelopeJunctions + compiledArea.comparison.nativeFallbackJunctions, compiledArea.comparison.nativeJunctionSurfaceFeatures);
assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0);
assert.equal(comparison.schema, "native-road-comparison/v2");
assert.equal(comparison.nativeRoadCount, compiled.model.roads.length);
assert.equal(comparison.nativePublishedMovementCount, compiled.movements.filter((movement) => movement.geometryPublished).length);
assert.equal(comparison.nativeCrosswalkFeatures, 0);
assert.equal(comparison.nativeVehicleStopLineFeatures, 0);
assert.equal(comparison.nativeApproachEnvelopeJunctions + comparison.nativeFallbackJunctions, comparison.nativeJunctionSurfaceFeatures);
assert.ok(comparison.nativeMaxJunctionExpansionRatio >= 0);
assert.equal(checkArea(config).ok, true);
} finally {
fs.rmSync(freshArea, { recursive: true, force: true });

View File

@@ -22,8 +22,7 @@ const {
uTurnConnector,
} = require("./lib/vehicle-route");
const { buildTrafficSignals } = require("./lib/traffic-signals");
const { haversineMeters, laneCenterline } = require("../packages/road-compiler/src/geometry/lane-geometry");
const { parseOsm } = require("../packages/road-compiler/src/osm");
const { laneGeometry: { haversineMeters, laneCenterline }, osm: { parseOsm } } = require("@osm-asset/road-compiler");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
const osmPath = path.join(tempDir, "fixture.osm");

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert/strict");
const { parseCompletionMarker } = require("./lib/road-compiler-cli");
const input = { areaId: "fixture", outDir: "/tmp/fixture/native-road" };
const marker = `NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: input.areaId, output: input.outDir, roads: 1, endpoints: 2, diagnostics: 0 })}`;
assert.equal(parseCompletionMarker(marker, input).areaId, input.areaId);
assert.throws(() => parseCompletionMarker("compiler output", input), /exactly one/);
assert.throws(() => parseCompletionMarker(`${marker}\n${marker}`, input), /exactly one/);
assert.throws(() => parseCompletionMarker("NATIVE_ROAD_COMPILE_DONE {", input), /Invalid/);
assert.throws(() => parseCompletionMarker("NATIVE_ROAD_COMPILE_DONE {\"areaId\":\"other\",\"output\":\"/tmp/fixture/native-road\"}", input), /areaId mismatch/);
assert.throws(() => parseCompletionMarker("NATIVE_ROAD_COMPILE_DONE {\"areaId\":\"fixture\",\"output\":\"/tmp/other\"}", input), /output mismatch/);
console.log("road compiler CLI tests passed");

View File

@@ -5,7 +5,8 @@ const assert = require("assert");
const fs = require("fs");
const path = require("path");
const client = path.join(__dirname, "..", "packages", "road-compiler", "workbench", "client");
const packageRoot = path.dirname(require.resolve("@osm-asset/road-compiler/package.json"));
const client = path.join(packageRoot, "workbench", "client");
const html = fs.readFileSync(path.join(client, "index.html"), "utf8");
assert.match(html, /id="width" type="number" min="1" step="0\.01"/);
assert.match(html, /data-layer="sidewalks" type="checkbox" checked> 路缘与步行带/);
@@ -81,7 +82,7 @@ assert.match(app, /fromLonLat/);
assert.match(app, /armFeatures\.push\(new Feature/);
assert.match(app, /headFeatures\.push\(new Feature/);
assert.match(app, /faceFeatures\.push\(new Feature/);
const server = fs.readFileSync(path.join(__dirname, "..", "packages", "road-compiler", "workbench", "server.js"), "utf8");
const server = fs.readFileSync(path.join(packageRoot, "workbench", "server.js"), "utf8");
assert.match(server, /\/api\/traffic-signals\/generate/);
assert.match(server, /function startWorkbench\(/);
assert.match(server, /context\.compileFresh\(\)/, "workbench regeneration must use the host-provided fresh compiler callback");

View File

@@ -11,8 +11,8 @@ const {
buildTrafficSignalsFromFeatures,
validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences,
} = require("../packages/road-compiler/src/traffic-signals");
const { SCHEMA, loadOrGenerate } = require("../packages/road-compiler/src/native-traffic-signals");
} = require("@osm-asset/road-compiler").trafficSignals;
const { SCHEMA, loadOrGenerate } = require("@osm-asset/road-compiler").nativeTrafficSignals;
function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) {
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[

View File

@@ -2,7 +2,7 @@
"use strict";
const assert = require("assert");
const { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("../packages/road-compiler/src/compile/turn-lane-arrows");
const { turnLaneArrows: { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } } = require("@osm-asset/road-compiler");
function node(id, lon, lat) { return { id, lon, lat, tags: {} }; }
function way(id, refs, tags) { return { id, refs, tags }; }

View File

@@ -5,7 +5,7 @@ const assert = require("assert");
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const { gcj02ToWgs84: referenceGcj02ToWgs84 } = require("../packages/road-compiler/src/reference/gaode");
const { gaodeReference: { gcj02ToWgs84: referenceGcj02ToWgs84 } } = require("@osm-asset/road-compiler");
const source = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.js"), "utf8");
const context = {