feat: migrate workbench to React

This commit is contained in:
2026-08-26 15:01:07 +08:00
parent f15e69c868
commit 3ddb33e321
48 changed files with 16748 additions and 1509 deletions

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
workbench/client/dist/
.trellis/

5
.prettierrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -0,0 +1,37 @@
# Quality Guidelines
> Code quality standards for frontend development.
---
## Overview
The workbench frontend uses Prettier as the source formatting authority. Code is formatted before review; formatting is not left to individual editor settings.
---
## Forbidden Patterns
- Do not commit dense, manually minified, or single-line JSX/TypeScript.
- Do not hand-format around Prettier output.
---
## Required Patterns
- Run `npm run format` after editing frontend files.
- Run `npm run format:check` in the validation gate.
- Keep React components, hooks, types, and API clients in separately formatted modules once a component becomes non-trivial.
---
## Testing Requirements
- Run `npm run format:check`, `npm run test:client`, and `npm run build` for frontend changes.
---
## Code Review Checklist
- Formatting check passes.
- UI behavior changes do not rebuild the OpenLayers map unless the map lifecycle explicitly requires it.

View File

@@ -0,0 +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/frontend/index.md","reason":"Review frontend quality, component, hook, and state conventions."}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Verify API-to-React-to-OpenLayers data flow and cleanup boundaries."}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Check that migration does not leave duplicate DOM and React implementations or duplicate map logic."}

View File

@@ -0,0 +1,32 @@
# Technical Design
## Frontend Boundary
Create a Vite app under `workbench/client/` (or a clearly scoped `workbench/frontend/` directory) with React 19 and TypeScript. Keep Node/server code CommonJS and keep `/api/*` payloads unchanged. The production build should emit a deterministic directory consumed by `workbench/server.js`; development mode may use a Vite proxy to the workbench API.
## OpenLayers Migration Strategy
- Import OpenLayers modules directly from the installed `ol` package in TypeScript; remove the runtime `/vendor/ol/*.js` import map from the React build.
- Keep one `Map` instance per mounted map component. A `useOpenLayersMap` hook creates it once after the container ref is available, registers interactions/listeners, and calls `map.setTarget(undefined)` plus listener cleanup on unmount.
- Keep the layer registry in an adapter module/hook (`map/layers.ts`), created once with stable `VectorSource` and `VectorLayer` instances. React state changes update sources, styles, visibility, and selection overlays through explicit adapter methods rather than recreating the map.
- Use typed GeoJSON/read helpers at the API boundary; preserve EPSG:4326 to EPSG:3857 conversion and current fit/selection behavior.
- OpenLayers event callbacks publish typed selection/pointer events to React state. React panels render from that state and issue API mutations; they do not query or mutate DOM nodes owned by OpenLayers.
- Import `ol/ol.css` from the Vite entry and keep map container dimensions in application CSS.
## UI and State
- Use shadcn/ui primitives for buttons, inputs, checkboxes, select, tabs/segmented filters, dialogs, sheets, alerts, and toast feedback. Keep the existing dense three-column workbench layout.
- Define API/state types in `src/types/` based on the actual `/api/state` and mutation payloads. Use a small typed client in `src/lib/api.ts`.
- Use React hooks/context for session and editor state. Keep transient map selection and staged overrides separate from server state; avoid introducing a large state library unless the migration proves one necessary.
- Split components by existing workflow boundaries: `ImportScreen`, `WorkbenchShell`, `LayerPanel`, `DiagnosticsPanel`, `MapCanvas`, `RoadInspector`, `SignalEditor`, and shared `ui/` primitives.
## Static Hosting
- Update `workbench/server.js` to serve Vite `index.html` and hashed assets in production, with a safe fallback for the SPA route while retaining `/vendor/*` only for compatibility during transition.
- Keep `npm run road:workbench` self-contained for production assets. Add Vite dev/proxy scripts without requiring the Node workbench server to become an ESM application.
## Risks and Compatibility
- OpenLayers owns imperative objects and must not be recreated on every React render; the hook/adapter boundary is mandatory.
- Existing style functions close over selected road state. Convert them to adapter-managed mutable selection refs or call `layer.changed()` after selection updates.
- The first migration should preserve behavior and layout; visual redesign is explicitly deferred.

View File

@@ -0,0 +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/frontend/index.md","reason":"Load the project frontend conventions before introducing Vite, React, TypeScript, and shadcn/ui."}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"The migration changes the browser build, static hosting, API client, and imperative map lifecycle together."}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Reuse current OpenLayers styles, API payloads, and editor behavior instead of duplicating domain logic."}

View File

@@ -0,0 +1,20 @@
# Implementation Plan
1. Add Vite, React 19, TypeScript, Tailwind/shadcn/ui dependencies and scripts; establish `tsconfig`, Vite config, aliases, CSS variables, and production output location.
2. Define typed API/state contracts and a fetch client from the existing `/api/*` payloads; add focused serialization tests.
3. Build the OpenLayers adapter and `useOpenLayersMap` hook. Port the current layer registry, styles, source updates, selection interaction, fit behavior, and cleanup tests before moving inspector UI.
4. Port the import-first flow and shell/layout to React, then migrate layer controls, summaries, diagnostics, road inspector, connection editor, centerline editor, signal editor, and save/compile actions.
5. Replace the server's legacy `/app.js`/`/app.css` production serving with Vite build output while retaining a compatibility path only where needed for rollback.
6. Remove the old DOM-driven entry after parity is demonstrated; keep OpenLayers vendor serving only if legacy mode still needs it.
7. Add browser/component smoke coverage for no-session import, active-session map render, a road selection/edit, and compile/export request wiring.
8. Run `npm run build`, frontend tests, existing `npm test`, and a manual workbench smoke check at desktop/mobile widths; fix parity regressions.
Validation commands:
- `npm run build`
- `npm run format:check`
- frontend test command added by this task
- `npm test`
- `node --check workbench/server.js`
Rollback point: retain the legacy client in a separate compatibility path until the React build serves the same API workflow and OpenLayers interaction checks pass.

View File

@@ -0,0 +1,43 @@
# 迁移工作台到 React 前端
## Goal
将现有原生 HTML/CSS/JS 工作台迁移到 Vite、React 19、TypeScript 和 shadcn/ui获得可维护、可扩展且具备类型安全的成熟前端工程结构同时保留当前 OSM 导入、地图查看、道路参数编辑、诊断、信号灯编辑、重新编译和道路包导出能力。
## Confirmed Facts
- 当前前端位于 `workbench/client/index.html``workbench/client/app.js``workbench/client/app.css`
- `workbench/client/app.js` 使用 OpenLayers 浏览器模块,并动态创建部分控制面板和图层开关。
- `workbench/server.js` 提供静态资源和 `/api/state``/api/import``/api/overrides``/api/traffic-signals``/api/compile``/api/export.zip` 等接口。
- 当前项目是 CommonJS Node 包尚无前端构建脚本、TypeScript 配置、Vite 配置或 shadcn/ui 依赖。
- 地图核心继续使用已有的 OpenLayers 10本任务是前端工程迁移不替换地图引擎。
## Requirements
1. 建立独立、可构建的 Vite React 19 TypeScript 前端入口并迁移工作台代码。
2. 配置 shadcn/ui 所需的 CSS 变量、组件基础设施和图标/交互约定,优先使用其组件承载按钮、表单、面板、弹层和提示。
3. 将页面状态、API 调用、地图图层/选择逻辑和编辑表单拆分为 React 组件、hooks、类型和服务模块避免主要业务 UI 继续依赖全局 `querySelector` 和动态 `innerHTML`
4. 保留现有服务端 API 契约和 OSM 导入工作流;必要时只调整静态资源托管和开发/生产构建入口。
5. 保留 OpenLayers 地图的现有图层、样式、选择和交互行为,并确保状态变化正确驱动地图与检查器更新。
6. 提供开发模式和生产构建命令Node 工作台服务能提供构建后的前端资源。
7. 为核心 API 客户端、状态转换和至少一个关键交互增加 TypeScript/自动化验证。
## Acceptance Criteria
- `npm run build` 能在干净依赖安装后生成前端生产构建产物。
- `npm run dev`(或等价命令)能启动 Vite 开发服务器并加载工作台页面。
- `npm run road:workbench` 使用构建产物时仍能打开工作台;无活动工作区时显示 OSM 导入界面,有活动工作区时显示地图和编辑器。
- OSM 导入、图层开关、道路选择/参数暂存、诊断筛选、交通信号编辑、保存、重新编译和 ZIP 导出均保持可用。
- 页面不再通过原生 `querySelector`/`innerHTML` 组织主要业务 UI业务状态和 API 数据具有明确 TypeScript 类型。
- 现有 Node 编译器测试继续通过;新增前端构建和关键工作流验证通过。
## Out Of Scope
- 不修改道路编译算法、OSM 解析规则、GeoJSON 数据结构或现有 HTTP API 语义。
- 不更换 OpenLayers 为其他地图引擎。
- 不引入认证、多用户协作、服务端渲染或远程部署平台。
- 不在本任务内重新定义道路参数模型或增加新的业务功能。
## Open Product Decision
迁移是否同时允许明显的视觉和信息架构重设计?推荐第一阶段以“功能和工作流等价 + shadcn/ui 统一视觉”为目标,保留现有三栏地图工作台布局;这样可以把风险集中在工程迁移,后续再单独做 UX 重构。若本任务同时重做布局,交付周期和回归范围会显著增加。

View File

@@ -0,0 +1,26 @@
{
"id": "react-workbench-migration",
"name": "react-workbench-migration",
"title": "迁移工作台到 React 前端",
"description": "将现有原生 HTML/CSS/JS 工作台迁移到 Vite、React 19、TypeScript 和 shadcn/ui保持现有工作流能力",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -6,19 +6,81 @@
"license_file": "upstream/LICENSE.md" "license_file": "upstream/LICENSE.md"
}, },
"assets": [ "assets": [
{"id":"through","source":"upstream/through.svg","anchor_x":12.5,"supported":true,"tested":true,"template":"through"}, {
{"id":"left","source":"upstream/left.svg","anchor_x":17,"supported":true,"tested":true,"template":"left"}, "id": "through",
{"id":"right","source":"upstream/left.svg","derived_from":"left","mirror_x":true,"anchor_x":17,"supported":true,"tested":true,"template":"right"}, "source": "upstream/through.svg",
{"id":"through;left","source":"upstream/left-through.svg","anchor_x":17,"supported":true,"tested":true,"template":"through_left"}, "anchor_x": 12.5,
{"id":"through;right","source":"upstream/left-through.svg","derived_from":"through;left","mirror_x":true,"anchor_x":17,"supported":true,"tested":true,"template":"through_right"}, "supported": true,
{"id":"through;left;right","source":"upstream/left-slight_left-through.svg","derived_from":"through;left + through;right","anchor_x":17,"supported":true,"tested":true,"template":"through_left_right"}, "tested": true,
{"id":"slight_left","source":"upstream/slight_left.svg","supported":false,"tested":false}, "template": "through"
{"id":"slight_left;through","source":"upstream/slight_left-through.svg","supported":false,"tested":false}, },
{"id":"left;slight_left;through","source":"upstream/left-slight_left-through.svg","supported":false,"tested":false}, {
{"id":"sharp_left","source":"upstream/sharp_left.svg","supported":false,"tested":false}, "id": "left",
{"id":"sharp_left;through","source":"upstream/sharp_left-through.svg","supported":false,"tested":false}, "source": "upstream/left.svg",
{"id":"reverse_left","source":"upstream/reverse_left.svg","supported":false,"tested":false}, "anchor_x": 17,
{"id":"reverse_left;through","source":"upstream/reverse_left-through.svg","supported":false,"tested":false}, "supported": true,
{"id":"reverse_left;left;slight_left;through","source":"upstream/reverse_left-left-slight_left-through.svg","supported":false,"tested":false} "tested": true,
"template": "left"
},
{
"id": "right",
"source": "upstream/left.svg",
"derived_from": "left",
"mirror_x": true,
"anchor_x": 17,
"supported": true,
"tested": true,
"template": "right"
},
{
"id": "through;left",
"source": "upstream/left-through.svg",
"anchor_x": 17,
"supported": true,
"tested": true,
"template": "through_left"
},
{
"id": "through;right",
"source": "upstream/left-through.svg",
"derived_from": "through;left",
"mirror_x": true,
"anchor_x": 17,
"supported": true,
"tested": true,
"template": "through_right"
},
{
"id": "through;left;right",
"source": "upstream/left-slight_left-through.svg",
"derived_from": "through;left + through;right",
"anchor_x": 17,
"supported": true,
"tested": true,
"template": "through_left_right"
},
{ "id": "slight_left", "source": "upstream/slight_left.svg", "supported": false, "tested": false },
{ "id": "slight_left;through", "source": "upstream/slight_left-through.svg", "supported": false, "tested": false },
{
"id": "left;slight_left;through",
"source": "upstream/left-slight_left-through.svg",
"supported": false,
"tested": false
},
{ "id": "sharp_left", "source": "upstream/sharp_left.svg", "supported": false, "tested": false },
{ "id": "sharp_left;through", "source": "upstream/sharp_left-through.svg", "supported": false, "tested": false },
{ "id": "reverse_left", "source": "upstream/reverse_left.svg", "supported": false, "tested": false },
{
"id": "reverse_left;through",
"source": "upstream/reverse_left-through.svg",
"supported": false,
"tested": false
},
{
"id": "reverse_left;left;slight_left;through",
"source": "upstream/reverse_left-left-slight_left-through.svg",
"supported": false,
"tested": false
}
] ]
} }

View File

@@ -9,11 +9,11 @@ and as such it is not copyrighted.
Each icon should be Each icon should be
* 25px x 25px - 25px x 25px
* SVG - SVG
* Single color fill - Single color fill
* Path outline only, no stroke - Path outline only, no stroke
* Pixel grid aligned (where possible) - Pixel grid aligned (where possible)
### Arrows ### Arrows
@@ -25,7 +25,6 @@ lane arrows.
<img alt='Standard Arrow' width='300px' src='docs/standard_arrow.png'/> <img alt='Standard Arrow' width='300px' src='docs/standard_arrow.png'/>
Arrowhead rotation angles are chosen based on the turn lane indication: Arrowhead rotation angles are chosen based on the turn lane indication:
<table> <table>
@@ -84,13 +83,12 @@ As the number of arrows increases, the arrows scale down:
</tr> </tr>
</table> </table>
Other important rules for arrows: Other important rules for arrows:
* Bottom aligned with each other, 2px from icon bottom edge
* Left-right centered
* Smooth curves, no kinks
* Arrow shaft path connects to the arrowhead anchor point
- Bottom aligned with each other, 2px from icon bottom edge
- Left-right centered
- Smooth curves, no kinks
- Arrow shaft path connects to the arrowhead anchor point
### Other Icons ### Other Icons

View File

@@ -33,27 +33,27 @@ protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited Related Rights"). Copyright and Related Rights include, but are not limited
to, the following: to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate, i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work; and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s); ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work; depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work, iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below; subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in v. rights protecting the extraction, dissemination, use and reuse of data in
a Work; a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof, protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof. based on applicable law or treaty, and any national implementations thereof.
**2. Waiver.** To the greatest extent permitted by, but not in contravention of, **2. Waiver.** To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
@@ -94,25 +94,25 @@ Affirmer's express Statement of Purpose.
**4. Limitations and Disclaimers.** **4. Limitations and Disclaimers.**
a. No trademark or patent rights held by Affirmer are waived, abandoned, a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document. surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise, of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law. discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work. or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this party to this document and has no duty or obligation with respect to this
CC0 or use of the Work. CC0 or use of the Work.
For more information, please see For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/> <http://creativecommons.org/publicdomain/zero/1.0/>

View File

@@ -1,20 +1,22 @@
#!/usr/bin/env node #!/usr/bin/env node
"use strict"; 'use strict';
const fs = require("fs"); const fs = require('fs');
const path = require("path"); const path = require('path');
const { compiler } = require("../src"); const { compiler } = require('../src');
const { exportNativeRoadPackage } = require("../src/export/native-road-package"); const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const args = process.argv.slice(2); const args = process.argv.slice(2);
const index = args.indexOf("--input"); const index = args.indexOf('--input');
const exportIndex = args.indexOf("--export-zip"); const exportIndex = args.indexOf('--export-zip');
const expectedLength = exportIndex >= 0 ? 4 : 2; const expectedLength = exportIndex >= 0 ? 4 : 2;
if (index < 0 || !args[index + 1] || (exportIndex >= 0 && !args[exportIndex + 1]) || args.length !== expectedLength) { if (index < 0 || !args[index + 1] || (exportIndex >= 0 && !args[exportIndex + 1]) || args.length !== expectedLength) {
throw new Error("Usage: road-compiler --input <RoadCompilerInput.json> [--export-zip <output.zip>]"); throw new Error('Usage: road-compiler --input <RoadCompilerInput.json> [--export-zip <output.zip>]');
} }
const inputFile = path.resolve(args[index + 1]); const inputFile = path.resolve(args[index + 1]);
const input = JSON.parse(fs.readFileSync(inputFile, "utf8")); const input = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
const { result, comparison } = compiler.compileInput(input); const { result, comparison } = compiler.compileInput(input);
if (exportIndex >= 0) exportNativeRoadPackage(input.outDir, path.resolve(args[exportIndex + 1])); if (exportIndex >= 0) exportNativeRoadPackage(input.outDir, path.resolve(args[exportIndex + 1]));
console.log(`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: input.areaId, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: input.outDir, comparison })}`); console.log(
`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: input.areaId, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: input.outDir, comparison })}`,
);

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env node #!/usr/bin/env node
"use strict"; 'use strict';
const path = require("path"); const path = require('path');
const { startWorkbench } = require("../workbench/server"); const { startWorkbench } = require('../workbench/server');
const fs = require("fs"); const fs = require('fs');
const args = process.argv.slice(2); const args = process.argv.slice(2);
const index = args.indexOf("--input"); const index = args.indexOf('--input');
const inputFile = index >= 0 && args[index + 1] ? path.resolve(args[index + 1]) : null; const inputFile = index >= 0 && args[index + 1] ? path.resolve(args[index + 1]) : null;
const input = inputFile ? JSON.parse(fs.readFileSync(inputFile, "utf8")) : null; const input = inputFile ? JSON.parse(fs.readFileSync(inputFile, 'utf8')) : null;
const portIndex = args.indexOf("--port"); const portIndex = args.indexOf('--port');
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 8787; const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 8787;
startWorkbench({ input, inputFile, repoRoot: path.resolve(__dirname, ".."), port }); startWorkbench({ input, inputFile, repoRoot: path.resolve(__dirname, '..'), port });

1143
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,25 @@
"scripts": { "scripts": {
"test": "node test/index.js && node test/fixtures.js", "test": "node test/index.js && node test/fixtures.js",
"road:workbench": "node bin/road-workbench.js", "road:workbench": "node bin/road-workbench.js",
"road:export": "node bin/road-compiler.js" "road:export": "node bin/road-compiler.js",
"build": "vite build --config workbench/client/vite.config.ts",
"dev": "vite --config workbench/client/vite.config.ts",
"test:client": "tsc --noEmit -p workbench/client/tsconfig.json",
"format": "prettier --write \"{src,bin,test,workbench}/**/*.{js,ts,tsx,css,html}\" \"*.{json,js}\"",
"format:check": "prettier --check \"{src,bin,test,workbench}/**/*.{js,ts,tsx,css,html}\" \"*.{json,js}\""
}, },
"dependencies": { "dependencies": {
"fflate": "0.8.3", "fflate": "0.8.3",
"ol": "10.10.0" "lucide-react": "^0.468.0",
"ol": "10.10.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"prettier": "^3.9.6",
"typescript": "^5.7.3",
"vite": "^6.1.0"
} }
} }

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
"use strict"; 'use strict';
const fs = require("fs"); const fs = require('fs');
const { convertGeoJson, boundsOf } = require("../reference/gaode"); const { convertGeoJson, boundsOf } = require('../reference/gaode');
const metricsCache = new WeakMap(); const metricsCache = new WeakMap();
const CORNER_FILLET_SEGMENTS = 12; const CORNER_FILLET_SEGMENTS = 12;
// Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band // Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band
@@ -15,8 +15,24 @@ const SIDEWALK_CORNER_OVERRUN_METERS = 6;
function buildComplexJunctionGeometry(model, cluster, helpers) { function buildComplexJunctionGeometry(model, cluster, helpers) {
const nodeIds = new Set(cluster.nodeIds.map(String)); const nodeIds = new Set(cluster.nodeIds.map(String));
const nodes = cluster.nodeIds.map((id) => helpers.junctionPlans.get(String(id))?.node).filter(Boolean); 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)] }; if (nodes.length < 2)
const center = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]); 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 approaches = [];
const carriageways = []; const carriageways = [];
for (const [nodeId, plan] of helpers.junctionPlans) { for (const [nodeId, plan] of helpers.junctionPlans) {
@@ -31,39 +47,92 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
approaches.push({ nodeId, approach, plan, heading, length }); 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)] }; 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 { calibration, coreRadius } = complexJunctionMetrics(cluster);
const sorted = [...approaches].sort((a, b) => a.heading - b.heading); const sorted = [...approaches].sort((a, b) => a.heading - b.heading);
const arms = sorted.map((representative) => ({ const arms = sorted.map((representative) => ({
representative, representative,
heading: averageHeading(carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20).map((candidate) => candidate.heading)), heading: averageHeading(
members: carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20), 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 outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
const boundaryParts = []; const boundaryParts = [];
const crosswalks = []; const crosswalks = [];
const stopLines = []; const stopLines = [];
const islands = []; const islands = [];
const armCrosswalkRadius = coreRadius * .68; const armCrosswalkRadius = coreRadius * 0.68;
for (const item of carriageways) { for (const item of carriageways) {
const outer = pointOnCarriagewayRadius(item, center, outerRadius, helpers); const outer = pointOnCarriagewayRadius(item, center, outerRadius, helpers);
const inner = pointOnCarriagewayRadius(item, center, coreRadius * .7, helpers); const inner = pointOnCarriagewayRadius(item, center, coreRadius * 0.7, helpers);
const outerHalf = item.approach.widthMeters / 2; const outerHalf = item.approach.widthMeters / 2;
const innerHalf = outerHalf; const innerHalf = outerHalf;
boundaryParts.push({ item, outer, inner, outerHalf, innerHalf }); 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)); 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) { if (incomingRoad) {
// Keep the stop bar just outside the road crosswalk. The previous fixed // Keep the stop bar just outside the road crosswalk. The previous fixed
// core-radius offset placed it nearly ten metres beyond the crossing. // core-radius offset placed it nearly ten metres beyond the crossing.
const stopCenter = pointOnCarriagewayRadius(item, center, armCrosswalkRadius + 3, helpers); const stopCenter = pointOnCarriagewayRadius(item, center, armCrosswalkRadius + 3, helpers);
const ring = [ const ring = [
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24), helpers.offsetCoordinate(
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, -.24), helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, .24), item.heading,
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, .24), -0.24,
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24), ),
helpers.offsetCoordinate(
helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf),
item.heading,
-0.24,
),
helpers.offsetCoordinate(
helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf),
item.heading,
0.24,
),
helpers.offsetCoordinate(
helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf),
item.heading,
0.24,
),
helpers.offsetCoordinate(
helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf),
item.heading,
-0.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] } }); 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 // The four support lines provide a common corner frame, but each long
@@ -89,15 +158,15 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
const arm = arms[index]; const arm = arms[index];
if (!arm.crosswalkFrame) continue; if (!arm.crosswalkFrame) continue;
const item = arm.representative; const item = arm.representative;
const roadEdgeInset = .35; const roadEdgeInset = 0.35;
const usableSpan = Math.max(.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2); const usableSpan = Math.max(0.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2);
const endpoints = [ const endpoints = [
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, -usableSpan / 2), helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, -usableSpan / 2),
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 groupDepth = arm.crosswalkFrame.groupDepth;
const stripeWidth = .42; const stripeWidth = 0.42;
const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / .82) + 1); const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / 0.82) + 1);
const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0; const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0;
arm.crosswalkFrame.center = midpoint(...endpoints); arm.crosswalkFrame.center = midpoint(...endpoints);
arm.crosswalkFrame.endpoints = endpoints; arm.crosswalkFrame.endpoints = endpoints;
@@ -107,13 +176,54 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
const along = stripeWidth / 2 + stripe * stripeSpacing; const along = stripeWidth / 2 + stripe * stripeSpacing;
const centerPoint = helpers.offsetCoordinate(endpoints[0], bearing(...endpoints), along); const centerPoint = helpers.offsetCoordinate(endpoints[0], bearing(...endpoints), along);
const ring = [ const ring = [
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2), helpers.offsetCoordinate(
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, -stripeWidth / 2), helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, stripeWidth / 2), arm.heading + 90,
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, stripeWidth / 2), -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,
),
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] } }); 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 // The four arm groups are the sides of one pedestrian frame. Each diagonal
@@ -128,36 +238,65 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
const bisector = normalizeHeading(first.heading + delta / 2); const bisector = normalizeHeading(first.heading + delta / 2);
const frameCorner = frameCorners[index]; const frameCorner = frameCorners[index];
if (!frameCorner) continue; if (!frameCorner) continue;
const cornerStripeSpacing = .62; const cornerStripeSpacing = 0.62;
const cornerStripeWidth = .4; const cornerStripeWidth = 0.4;
const cornerGroupHalfDepth = (5 * cornerStripeSpacing + cornerStripeWidth) / 2; 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 endpointForCorner = (arm) =>
[...arm.crosswalkFrame.endpoints].sort(
(a, b) => helpers.distanceMeters(a, frameCorner) - helpers.distanceMeters(b, frameCorner),
)[0];
const outerEdgeAtCorner = (arm) => { const outerEdgeAtCorner = (arm) => {
const endpoint = endpointForCorner(arm); const endpoint = endpointForCorner(arm);
return [arm.heading, arm.heading + 180] return [arm.heading, arm.heading + 180]
.map((heading) => helpers.offsetCoordinate(endpoint, heading, arm.crosswalkFrame.groupDepth / 2)) .map((heading) => helpers.offsetCoordinate(endpoint, heading, arm.crosswalkFrame.groupDepth / 2))
.sort((a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector))[0]; .sort(
(a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector),
)[0];
}; };
const islandBaseGap = .05; const islandBaseGap = 0.05;
const islandApexOffset = 1.5; const islandApexOffset = 1.5;
const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) => helpers.offsetCoordinate(point, bisector, islandBaseGap)); const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) =>
helpers.offsetCoordinate(point, bisector, islandBaseGap),
);
const islandApex = helpers.offsetCoordinate(frameCorner, bisector, islandApexOffset); const islandApex = helpers.offsetCoordinate(frameCorner, bisector, islandApexOffset);
const islandCrossingClearance = .2; const islandCrossingClearance = 0.2;
const cornerCrossingOffset = islandApexOffset + islandCrossingClearance + cornerGroupHalfDepth; const cornerCrossingOffset = islandApexOffset + islandCrossingClearance + cornerGroupHalfDepth;
const islandInnerRadius = Math.min(...islandBase.map((point) => directionalProjectionMeters(center, point, bisector))); const islandInnerRadius = Math.min(
...islandBase.map((point) => directionalProjectionMeters(center, point, bisector)),
);
const islandOuterRadius = directionalProjectionMeters(center, islandApex, bisector); const islandOuterRadius = directionalProjectionMeters(center, islandApex, bisector);
const cornerCrossingCenter = helpers.offsetCoordinate(frameCorner, bisector, cornerCrossingOffset); const cornerCrossingCenter = helpers.offsetCoordinate(frameCorner, bisector, cornerCrossingOffset);
const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], .24); const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], 0.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] } }); 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: 0.24,
provenance: 'native-road-complex-junction-corner/v7-road-gap-fill',
},
geometry: { type: 'Polygon', coordinates: [islandRing] },
});
let cornerCrossingHalfSpan = .4; let cornerCrossingHalfSpan = 0.4;
for (let stripe = 0; stripe < 6; stripe += 1) { for (let stripe = 0; stripe < 6; stripe += 1) {
const stripeOffset = (stripe - 2.5) * cornerStripeSpacing; const stripeOffset = (stripe - 2.5) * cornerStripeSpacing;
const stripeCenter = helpers.offsetCoordinate(cornerCrossingCenter, bisector, stripeOffset); const stripeCenter = helpers.offsetCoordinate(cornerCrossingCenter, bisector, stripeOffset);
const stripeRadius = directionalProjectionMeters(center, stripeCenter, bisector); const stripeRadius = directionalProjectionMeters(center, stripeCenter, bisector);
const curbPair = limitedCornerPair(first, second, stripeRadius, bisector, center, 6.5, helpers); const curbPair = limitedCornerPair(first, second, stripeRadius, bisector, center, 6.5, helpers);
if (!curbPair) continue; if (!curbPair) continue;
const halfSpan = Math.max(.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2); const halfSpan = Math.max(0.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2);
cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan); cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan);
const stripePair = [ const stripePair = [
helpers.offsetCoordinate(stripeCenter, bisector - 90, halfSpan), helpers.offsetCoordinate(stripeCenter, bisector - 90, halfSpan),
@@ -170,7 +309,22 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
helpers.offsetCoordinate(stripePair[0], bisector, cornerStripeWidth / 2), helpers.offsetCoordinate(stripePair[0], 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] } }); 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 // Each carriageway ends in its own rectangle, so adjacent arms meet at a
@@ -195,17 +349,46 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
// outside that band the two edges are near parallel and any fillet fitted // outside that band the two edges are near parallel and any fillet fitted
// to them would sweep across the carriageways instead of the corner. // to them would sweep across the carriageways instead of the corner.
if (apexReach === null || apexReach < 1 || apexReach > outerRadius) { if (apexReach === null || apexReach < 1 || apexReach > outerRadius) {
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-corner-fillet-fallback", "该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。", center)); cornerDiagnostics.push(
helpers.diagnostic(
'warning',
`junction-cluster:${cluster.id}`,
[...nodeIds],
'complex-junction-corner-fillet-fallback',
'该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。',
center,
),
);
continue; continue;
} }
// Tangent distance for a circle of `cornerRadius` inscribed in a wedge of // Tangent distance for a circle of `cornerRadius` inscribed in a wedge of
// opening `delta`, clamped so the tangent points stay on the built arms. // 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 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 tangents = edges.map((edge) => helpers.offsetCoordinate(apex, edge.heading, tangentDistance));
const curve = quadraticCurve(tangents[0], apex, tangents[1], CORNER_FILLET_SEGMENTS); const curve = quadraticCurve(tangents[0], apex, tangents[1], CORNER_FILLET_SEGMENTS);
const ring = [...curve, center, curve[0]]; const ring = [...curve, center, curve[0]];
if (!ring.every((point) => point.every(Number.isFinite))) continue; 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] } }); 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 // 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 // two arms that both carry a footway still meet as two loose ends across
@@ -214,24 +397,108 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
// `second`, so each arm must carry the footway on that facing side. // `second`, so each arm must carry the footway on that facing side.
if (!armCarriesSidewalk(first, model, true) || !armCarriesSidewalk(second, model, false)) continue; if (!armCarriesSidewalk(first, model, true) || !armCarriesSidewalk(second, model, false)) continue;
const curb = [ const curb = [
...edgeRunToRadius(apex, edges[0], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers).reverse(), ...edgeRunToRadius(
apex,
edges[0],
tangentDistance,
outerRadius + SIDEWALK_CORNER_OVERRUN_METERS,
center,
helpers,
).reverse(),
...curve.slice(1, -1), ...curve.slice(1, -1),
...edgeRunToRadius(apex, edges[1], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers), ...edgeRunToRadius(
apex,
edges[1],
tangentDistance,
outerRadius + SIDEWALK_CORNER_OVERRUN_METERS,
center,
helpers,
),
]; ];
const outerEdge = offsetPolylineAwayFromCenter(curb, center, SIDEWALK_WIDTH_METERS, helpers); const outerEdge = offsetPolylineAwayFromCenter(curb, center, SIDEWALK_WIDTH_METERS, helpers);
const sidewalkRing = [...curb, ...outerEdge.slice().reverse(), curb[0]]; const sidewalkRing = [...curb, ...outerEdge.slice().reverse(), curb[0]];
if (!sidewalkRing.every((point) => point.every(Number.isFinite)) || ringSelfIntersects(sidewalkRing)) { 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)); cornerDiagnostics.push(
helpers.diagnostic(
'warning',
`junction-cluster:${cluster.id}`,
[...nodeIds],
'complex-junction-sidewalk-corner-fallback',
'该夹角的人行道转角几何自交或无效,已跳过,两侧步行带保持断开。',
center,
),
);
continue; 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] } }); 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 corePoints = boundaryParts
const coreRing = roundedPolygonRing(corePoints, .16); .flatMap(({ item, inner, innerHalf }) => [
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] } }]; 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, 0.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: 0.16,
provenance: 'native-road-complex-junction/v6-rounded-core',
},
geometry: { type: 'Polygon', coordinates: [coreRing] },
},
];
for (const { item, outer, inner, outerHalf, innerHalf } of boundaryParts) { 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)]; const ring = [
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] } }); 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 // Corner fills come last so they overlay the rectangular carriageway ends
// they are smoothing; they never replace an OSM-derived road surface. // they are smoothing; they never replace an OSM-derived road surface.
@@ -239,20 +506,56 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
// `coreRadiusMeters` is only consulted when there is no reference geometry. // `coreRadiusMeters` is only consulted when there is no reference geometry.
// Under calibration the radius comes from the reference span, so a configured // Under calibration the radius comes from the reference span, so a configured
// value that silently does nothing has to be reported, not swallowed. // 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 configuredRadiusIgnored =
calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > 0.5;
const configurationDiagnostics = configuredRadiusIgnored 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)] ? [
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)] }; 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) { function readReferenceCalibration(cluster) {
if (!cluster.referenceFile || !fs.existsSync(cluster.referenceFile)) return null; if (!cluster.referenceFile || !fs.existsSync(cluster.referenceFile)) return null;
try { try {
const converted = convertGeoJson(JSON.parse(fs.readFileSync(cluster.referenceFile, "utf8"))); 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 bounds = boundsOf({
const lonScale = 111320 * Math.cos(((bounds.minLat + bounds.maxLat) / 2) * Math.PI / 180); features: converted.features.filter((feature) => [1, 2, 3, 4].includes(Number(feature.properties?.type))),
return { longSpanMeters: (bounds.maxLon - bounds.minLon) * lonScale, shortSpanMeters: (bounds.maxLat - bounds.minLat) * 111320 }; });
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 (_) { } catch (_) {
return null; return null;
} }
@@ -268,29 +571,37 @@ function complexJunctionMetrics(cluster) {
// — a 24 m dual-carriageway arm was being fitted into a 16.6 m core no matter // — 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. // what the config asked for. The calibrated branch is unchanged.
const coreRadius = calibration const coreRadius = calibration
? Math.max(12, Math.min(24, calibration.shortSpanMeters * .14)) ? Math.max(12, Math.min(24, calibration.shortSpanMeters * 0.14))
: Math.max(12, Math.min(80, Number(cluster.coreRadiusMeters) || 28)); : Math.max(12, Math.min(80, Number(cluster.coreRadiusMeters) || 28));
const metrics = { calibration, coreRadius, approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18) }; const metrics = {
calibration,
coreRadius,
approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18),
};
metricsCache.set(cluster, metrics); metricsCache.set(cluster, metrics);
return metrics; return metrics;
} }
function normalizeHeading(value) { return ((value + 180) % 360 + 360) % 360 - 180; } function normalizeHeading(value) {
return ((((value + 180) % 360) + 360) % 360) - 180;
}
// `arm.heading` points outward from the junction, so the corner clockwise from // `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 // 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 // own sidewalk flags are relative to its digitisation direction, so flip them
// whenever the arm runs against that direction. // whenever the arm runs against that direction.
function armCarriesSidewalk(arm, model, cornerIsClockwise) { function armCarriesSidewalk(arm, model, cornerIsClockwise) {
return arm.members.some((member) => member.approach.roadIds return arm.members.some((member) =>
.map((roadId) => model.roads.find((road) => road.id === roadId)) member.approach.roadIds
.filter(Boolean) .map((roadId) => model.roads.find((road) => road.id === roadId))
.some((road) => { .filter(Boolean)
const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId); .some((road) => {
const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft; const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId);
const onCounterClockwiseSide = outwardIsForward ? road.sidewalkLeft : road.sidewalkRight; const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft;
return Boolean(cornerIsClockwise ? onClockwiseSide : onCounterClockwiseSide); 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 // Walk outward along a wedge edge from its tangent point until the curb reaches
@@ -322,8 +633,10 @@ function offsetPolylineAwayFromCenter(points, center, meters, helpers) {
function ringSelfIntersects(ring) { 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 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 straddles = (p1, p2, p3, p4) => {
const d1 = cross(p3, p4, p1); const d2 = cross(p3, p4, p2); const d1 = cross(p3, p4, p1);
const d3 = cross(p1, p2, p3); const d4 = cross(p1, p2, p4); 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)); 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 first = 0; first < ring.length - 1; first += 1) {
@@ -335,27 +648,36 @@ function ringSelfIntersects(ring) {
return false; return false;
} }
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); } function angleAround(center, point) {
return Math.atan2(point[1] - center[1], point[0] - center[0]);
}
function signedLateralMeters(origin, point, heading) { function signedLateralMeters(origin, point, heading) {
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180); const east = (point[0] - origin[0]) * 111320 * Math.cos((origin[1] * Math.PI) / 180);
const north = (point[1] - origin[1]) * 111320; const north = (point[1] - origin[1]) * 111320;
const radians = (heading + 90) * Math.PI / 180; const radians = ((heading + 90) * Math.PI) / 180;
return east * Math.sin(radians) + north * Math.cos(radians); return east * Math.sin(radians) + north * Math.cos(radians);
} }
function bearing(first, second) { function bearing(first, second) {
const east = (second[0] - first[0]) * Math.cos(first[1] * Math.PI / 180); const east = (second[0] - first[0]) * Math.cos((first[1] * Math.PI) / 180);
const north = second[1] - first[1]; const north = second[1] - first[1];
return Math.atan2(east, north) * 180 / Math.PI; return (Math.atan2(east, north) * 180) / Math.PI;
}
function midpoint(first, second) {
return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2];
} }
function midpoint(first, second) { return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]; }
function averageHeading(headings) { function averageHeading(headings) {
const vector = headings.reduce((sum, heading) => { const vector = headings.reduce(
const radians = heading * Math.PI / 180; (sum, heading) => {
return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)]; const radians = (heading * Math.PI) / 180;
}, [0, 0]); return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)];
return Math.atan2(vector[0], vector[1]) * 180 / Math.PI; },
[0, 0],
);
return (Math.atan2(vector[0], vector[1]) * 180) / Math.PI;
}
function positiveHeadingDelta(first, second) {
return (((second - first) % 360) + 360) % 360;
} }
function positiveHeadingDelta(first, second) { return ((second - first) % 360 + 360) % 360; }
function pointOnCarriagewayRadius(item, center, radius, helpers) { function pointOnCarriagewayRadius(item, center, radius, helpers) {
const start = item.approach.line[0]; const start = item.approach.line[0];
const startRadius = directionalProjectionMeters(center, start, item.heading); const startRadius = directionalProjectionMeters(center, start, item.heading);
@@ -374,15 +696,18 @@ function armEnvelopeAtRadius(arm, radius, center, helpers) {
maximum = Math.max(maximum, lateral + halfWidth); maximum = Math.max(maximum, lateral + halfWidth);
}); });
if (!Number.isFinite(minimum) || maximum - minimum < 1) return null; if (!Number.isFinite(minimum) || maximum - minimum < 1) return null;
return { center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2), widthMeters: maximum - minimum }; return {
center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2),
widthMeters: maximum - minimum,
};
} }
function supportLineIntersection(first, second, origin) { function supportLineIntersection(first, second, origin) {
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180); const lonScale = 111320 * Math.cos((origin[1] * Math.PI) / 180);
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320]; const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
const firstPoint = toLocal(first.center); const firstPoint = toLocal(first.center);
const secondPoint = toLocal(second.center); const secondPoint = toLocal(second.center);
const direction = (heading) => { const direction = (heading) => {
const radians = heading * Math.PI / 180; const radians = (heading * Math.PI) / 180;
return [Math.sin(radians), Math.cos(radians)]; return [Math.sin(radians), Math.cos(radians)];
}; };
const firstDirection = direction(first.supportHeading); const firstDirection = direction(first.supportHeading);
@@ -391,17 +716,26 @@ function supportLineIntersection(first, second, origin) {
if (Math.abs(denominator) < 1e-6) return null; if (Math.abs(denominator) < 1e-6) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]]; const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator; const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
const intersection = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst]; const intersection = [
firstPoint[0] + firstDirection[0] * distanceAlongFirst,
firstPoint[1] + firstDirection[1] * distanceAlongFirst,
];
return [origin[0] + intersection[0] / lonScale, origin[1] + intersection[1] / 111320]; return [origin[0] + intersection[0] / lonScale, origin[1] + intersection[1] / 111320];
} }
function limitedCornerPair(first, second, radius, bisector, center, maxWidth, helpers) { function limitedCornerPair(first, second, radius, bisector, center, maxWidth, helpers) {
const pair = [cornerEdgeAtRadius(first, radius, bisector, center, helpers), cornerEdgeAtRadius(second, radius, bisector, center, helpers)]; const pair = [
cornerEdgeAtRadius(first, radius, bisector, center, helpers),
cornerEdgeAtRadius(second, radius, bisector, center, helpers),
];
if (!pair.every(Boolean)) return null; if (!pair.every(Boolean)) return null;
const width = helpers.distanceMeters(pair[0], pair[1]); const width = helpers.distanceMeters(pair[0], pair[1]);
const middle = midpoint(pair[0], pair[1]); const middle = midpoint(pair[0], pair[1]);
const halfWidth = Math.max(.4, Math.min(width, maxWidth) / 2); const halfWidth = Math.max(0.4, Math.min(width, maxWidth) / 2);
const acrossHeading = width > .1 ? bearing(pair[0], pair[1]) : bisector + 90; const acrossHeading = width > 0.1 ? bearing(pair[0], pair[1]) : bisector + 90;
return [helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth), helpers.offsetCoordinate(middle, acrossHeading, halfWidth)]; return [
helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth),
helpers.offsetCoordinate(middle, acrossHeading, halfWidth),
];
} }
function cornerEdgeAtRadius(arm, radius, bisector, center, helpers) { function cornerEdgeAtRadius(arm, radius, bisector, center, helpers) {
return cornerEdgeAt(arm, radius, bisector, center, helpers)?.point || null; return cornerEdgeAt(arm, radius, bisector, center, helpers)?.point || null;
@@ -410,15 +744,24 @@ function cornerEdgeAt(arm, radius, bisector, center, helpers) {
const candidates = arm.members.flatMap((member) => { const candidates = arm.members.flatMap((member) => {
const point = pointOnCarriagewayRadius(member, center, radius, helpers); const point = pointOnCarriagewayRadius(member, center, radius, helpers);
const halfWidth = member.approach.widthMeters / 2; const halfWidth = member.approach.widthMeters / 2;
return [90, -90].map((side) => ({ point: helpers.offsetCoordinate(point, member.heading + side, halfWidth), heading: member.heading })); 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; return (
candidates.sort(
(first, second) =>
directionalProjectionMeters(center, second.point, bisector) -
directionalProjectionMeters(center, first.point, bisector),
)[0] || null
);
} }
function rayIntersection(first, second, origin) { function rayIntersection(first, second, origin) {
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180); const lonScale = 111320 * Math.cos((origin[1] * Math.PI) / 180);
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320]; const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
const direction = (heading) => { const direction = (heading) => {
const radians = heading * Math.PI / 180; const radians = (heading * Math.PI) / 180;
return [Math.sin(radians), Math.cos(radians)]; return [Math.sin(radians), Math.cos(radians)];
}; };
const firstPoint = toLocal(first.point); const firstPoint = toLocal(first.point);
@@ -429,7 +772,10 @@ function rayIntersection(first, second, origin) {
if (Math.abs(denominator) < 1e-4) return null; if (Math.abs(denominator) < 1e-4) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]]; const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator; const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
const local = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst]; const local = [
firstPoint[0] + firstDirection[0] * distanceAlongFirst,
firstPoint[1] + firstDirection[1] * distanceAlongFirst,
];
if (!local.every(Number.isFinite)) return null; if (!local.every(Number.isFinite)) return null;
return [origin[0] + local[0] / lonScale, origin[1] + local[1] / 111320]; return [origin[0] + local[0] / lonScale, origin[1] + local[1] / 111320];
} }
@@ -438,14 +784,17 @@ function quadraticCurve(start, control, end, segments) {
for (let index = 0; index <= segments; index += 1) { for (let index = 0; index <= segments; index += 1) {
const t = index / segments; const t = index / segments;
const u = 1 - t; 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]]); 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; return result;
} }
function directionalProjectionMeters(origin, point, heading) { function directionalProjectionMeters(origin, point, heading) {
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180); const east = (point[0] - origin[0]) * 111320 * Math.cos((origin[1] * Math.PI) / 180);
const north = (point[1] - origin[1]) * 111320; const north = (point[1] - origin[1]) * 111320;
const radians = heading * Math.PI / 180; const radians = (heading * Math.PI) / 180;
return east * Math.sin(radians) + north * Math.cos(radians); return east * Math.sin(radians) + north * Math.cos(radians);
} }
function smoothClosedRing(vertices) { function smoothClosedRing(vertices) {
@@ -453,11 +802,18 @@ function smoothClosedRing(vertices) {
// carriageway bends slightly. Sort this local corner only around its own // carriageway bends slightly. Sort this local corner only around its own
// centroid before rounding, avoiding a self-crossing safety island while // centroid before rounding, avoiding a self-crossing safety island while
// keeping the global junction boundary fully OSM-driven. // 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 centroid = vertices.reduce(
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])); (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 points = ordered.flatMap((point, index) => {
const next = ordered[(index + 1) % ordered.length]; const next = ordered[(index + 1) % ordered.length];
return [interpolateCoordinate(point, next, .18), interpolateCoordinate(point, next, .82)]; return [interpolateCoordinate(point, next, 0.18), interpolateCoordinate(point, next, 0.82)];
}); });
return [...points, points[0]]; return [...points, points[0]];
} }
@@ -469,6 +825,8 @@ function roundedPolygonRing(vertices, ratio) {
}); });
return [...points, points[0]]; return [...points, points[0]];
} }
function interpolateCoordinate(first, second, ratio) { return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio]; } function interpolateCoordinate(first, second, ratio) {
return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio];
}
module.exports = { buildComplexJunctionGeometry, complexJunctionMetrics }; module.exports = { buildComplexJunctionGeometry, complexJunctionMetrics };

View File

@@ -1,26 +1,55 @@
"use strict"; 'use strict';
// The compiler's layer registry is the single source of truth for published // The compiler's layer registry is the single source of truth for published
// GeoJSON files and the Blender-facing manifest. Keep rendering details limited // GeoJSON files and the Blender-facing manifest. Keep rendering details limited
// to material slot names; the host owns the actual material definitions. // to material slot names; the host owns the actual material definitions.
const LAYER_REGISTRY = Object.freeze([ const LAYER_REGISTRY = Object.freeze([
{ key: "roadSurface", source: "road_surface", role: "surface", materialLayer: "road_surface" }, { key: 'roadSurface', source: 'road_surface', role: 'surface', materialLayer: 'road_surface' },
{ key: "edgeLines", source: "edge_lines", role: "marking", materialLayer: "lane_separators" }, { key: 'edgeLines', source: 'edge_lines', role: 'marking', materialLayer: 'lane_separators' },
{ key: "intersectionSurface", source: "intersection_surface", role: "surface", materialLayer: "intersection_surface" }, {
{ key: "sidewalkSurface", source: "sidewalk_surface", role: "surface", materialLayer: "sidewalks" }, key: 'intersectionSurface',
{ key: "laneSeparators", source: "lane_separators", role: "marking", materialLayer: "lane_separators", splitBy: { prop: "color", cases: [{ match: "yellow", material: "native_lane_separator_yellow" }, { default: true, material: "lane_separators" }] } }, source: 'intersection_surface',
{ key: "centerLines", source: "center_lines", role: "marking", materialLayer: "center_lines", splitBy: { prop: "color", cases: [{ match: "white", material: "native_center_line_white" }, { default: true, material: "center_lines" }] } }, role: 'surface',
{ key: "directionArrows", source: "direction_arrows", role: "marking", materialLayer: "lane_arrows_webscale" }, materialLayer: 'intersection_surface',
{ key: "turnArrows", source: "turn_arrows", role: "marking", materialLayer: "lane_arrows_webscale" }, },
{ key: "crosswalks", source: "crosswalks", role: "marking", materialLayer: "crosswalks" }, { key: 'sidewalkSurface', source: 'sidewalk_surface', role: 'surface', materialLayer: 'sidewalks' },
{ key: "vehicleStopLines", source: "vehicle_stop_lines", role: "marking", materialLayer: "vehicle_stop_lines" }, {
{ key: "laneCenterlines", source: "lane_centerlines", role: "semantic" }, key: 'laneSeparators',
{ key: "connectors", source: "connectors", role: "semantic" }, source: 'lane_separators',
role: 'marking',
materialLayer: 'lane_separators',
splitBy: {
prop: 'color',
cases: [
{ match: 'yellow', material: 'native_lane_separator_yellow' },
{ default: true, material: 'lane_separators' },
],
},
},
{
key: 'centerLines',
source: 'center_lines',
role: 'marking',
materialLayer: 'center_lines',
splitBy: {
prop: 'color',
cases: [
{ match: 'white', material: 'native_center_line_white' },
{ default: true, material: 'center_lines' },
],
},
},
{ key: 'directionArrows', source: 'direction_arrows', role: 'marking', materialLayer: 'lane_arrows_webscale' },
{ key: 'turnArrows', source: 'turn_arrows', role: 'marking', materialLayer: 'lane_arrows_webscale' },
{ key: 'crosswalks', source: 'crosswalks', role: 'marking', materialLayer: 'crosswalks' },
{ key: 'vehicleStopLines', source: 'vehicle_stop_lines', role: 'marking', materialLayer: 'vehicle_stop_lines' },
{ key: 'laneCenterlines', source: 'lane_centerlines', role: 'semantic' },
{ key: 'connectors', source: 'connectors', role: 'semantic' },
]); ]);
function manifestForArea(areaId) { function manifestForArea(areaId) {
return { return {
contract: "native-road-package/v1.1", contract: 'native-road-package/v1.1',
areaId, areaId,
layers: LAYER_REGISTRY.map(({ source, role, materialLayer, splitBy }) => ({ layers: LAYER_REGISTRY.map(({ source, role, materialLayer, splitBy }) => ({
source, source,
@@ -34,16 +63,20 @@ function manifestForArea(areaId) {
function validatePublishedLayers(directory, manifest) { function validatePublishedLayers(directory, manifest) {
const declared = new Set(); const declared = new Set();
for (const layer of manifest.layers) { for (const layer of manifest.layers) {
if (!layer || typeof layer.source !== "string" || declared.has(layer.source)) throw new Error("Manifest has duplicate or invalid source."); if (!layer || typeof layer.source !== 'string' || declared.has(layer.source))
throw new Error('Manifest has duplicate or invalid source.');
declared.add(layer.source); declared.add(layer.source);
const file = require("path").join(directory, "layers", `${layer.source}.geojson`); const file = require('path').join(directory, 'layers', `${layer.source}.geojson`);
if (!require("fs").existsSync(file)) throw new Error(`Manifest source is missing: ${layer.source}`); if (!require('fs').existsSync(file)) throw new Error(`Manifest source is missing: ${layer.source}`);
} }
const files = require("fs").existsSync(require("path").join(directory, "layers")) const files = require('fs').existsSync(require('path').join(directory, 'layers'))
? require("fs").readdirSync(require("path").join(directory, "layers")).filter((name) => name.endsWith(".geojson")).map((name) => name.slice(0, -8)) ? require('fs')
.readdirSync(require('path').join(directory, 'layers'))
.filter((name) => name.endsWith('.geojson'))
.map((name) => name.slice(0, -8))
: []; : [];
const extras = files.filter((source) => !declared.has(source)); const extras = files.filter((source) => !declared.has(source));
if (extras.length) throw new Error(`Unmanifested GeoJSON source: ${extras.join(", ")}`); if (extras.length) throw new Error(`Unmanifested GeoJSON source: ${extras.join(', ')}`);
} }
module.exports = { LAYER_REGISTRY, manifestForArea, validatePublishedLayers }; module.exports = { LAYER_REGISTRY, manifestForArea, validatePublishedLayers };

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,49 +1,54 @@
"use strict"; 'use strict';
const fs = require("fs"); const fs = require('fs');
const path = require("path"); const path = require('path');
const { zipSync } = require("fflate"); const { zipSync } = require('fflate');
const { LAYER_REGISTRY, validatePublishedLayers } = require("../compile/layer-manifest"); const { LAYER_REGISTRY, validatePublishedLayers } = require('../compile/layer-manifest');
const ROOT_FILES = [ const ROOT_FILES = [
"manifest.json", 'manifest.json',
"compiled.json", 'compiled.json',
"diagnostics.json", 'diagnostics.json',
"comparison.json", 'comparison.json',
"traffic-signal-assemblies.json", 'traffic-signal-assemblies.json',
"traffic-signals.json", 'traffic-signals.json',
]; ];
const GENERATOR = Object.freeze({ name: "road-compiler", version: "0.3.0" }); const GENERATOR = Object.freeze({ name: 'road-compiler', version: '0.3.0' });
function packageEntries(directory) { function packageEntries(directory) {
const manifestPath = path.join(directory, "manifest.json"); const manifestPath = path.join(directory, 'manifest.json');
if (!fs.existsSync(manifestPath)) throw new Error(`Native road package manifest is missing: ${manifestPath}`); if (!fs.existsSync(manifestPath)) throw new Error(`Native road package manifest is missing: ${manifestPath}`);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifest.contract !== "native-road-package/v1.1") throw new Error("Native road package requires native-road-package/v1.1."); if (manifest.contract !== 'native-road-package/v1.1')
if (!manifest.areaId || typeof manifest.areaId !== "string") throw new Error("Native road package manifest areaId is required."); throw new Error('Native road package requires native-road-package/v1.1.');
if (!manifest.areaId || typeof manifest.areaId !== 'string')
throw new Error('Native road package manifest areaId is required.');
validatePublishedLayers(directory, manifest); validatePublishedLayers(directory, manifest);
const files = []; const files = [];
for (const relative of ROOT_FILES) files.push(relative); for (const relative of ROOT_FILES) files.push(relative);
for (const layer of LAYER_REGISTRY) files.push(`layers/${layer.source}.geojson`); for (const layer of LAYER_REGISTRY) files.push(`layers/${layer.source}.geojson`);
for (const relative of files) { for (const relative of files) {
const file = path.join(directory, relative); const file = path.join(directory, relative);
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`Native road package file is missing: ${relative}`); if (!fs.existsSync(file) || !fs.statSync(file).isFile())
throw new Error(`Native road package file is missing: ${relative}`);
} }
return { manifest, files: [...new Set(files)].sort() }; return { manifest, files: [...new Set(files)].sort() };
} }
function exportNativeRoadPackage(directory, destination = null) { function exportNativeRoadPackage(directory, destination = null) {
const { manifest, files } = packageEntries(directory); const { manifest, files } = packageEntries(directory);
const entries = Object.fromEntries(files.map((relative) => [relative, fs.readFileSync(path.join(directory, relative))])); const entries = Object.fromEntries(
files.map((relative) => [relative, fs.readFileSync(path.join(directory, relative))]),
);
// The model/movements remain needed by host preview generation, but absolute // The model/movements remain needed by host preview generation, but absolute
// compiler-workspace paths are not part of the exported package contract. // compiler-workspace paths are not part of the exported package contract.
const compiled = JSON.parse(entries["compiled.json"].toString("utf8")); const compiled = JSON.parse(entries['compiled.json'].toString('utf8'));
delete compiled.source; delete compiled.source;
entries["compiled.json"] = Buffer.from(`${JSON.stringify(compiled, null, 2)}\n`); entries['compiled.json'] = Buffer.from(`${JSON.stringify(compiled, null, 2)}\n`);
entries["manifest.json"] = Buffer.from(`${JSON.stringify({ ...manifest, generator: GENERATOR }, null, 2)}\n`); entries['manifest.json'] = Buffer.from(`${JSON.stringify({ ...manifest, generator: GENERATOR }, null, 2)}\n`);
// ZIP timestamps start at 1980; a fixed value keeps repeated exports byte-stable. // ZIP timestamps start at 1980; a fixed value keeps repeated exports byte-stable.
const bytes = zipSync(entries, { level: 6, mtime: new Date("1980-01-01T00:00:00Z") }); const bytes = zipSync(entries, { level: 6, mtime: new Date('1980-01-01T00:00:00Z') });
if (destination) { if (destination) {
fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.mkdirSync(path.dirname(destination), { recursive: true });
const temporary = `${destination}.tmp-${process.pid}`; const temporary = `${destination}.tmp-${process.pid}`;

View File

@@ -1,18 +1,20 @@
"use strict"; 'use strict';
const EARTH_RADIUS_METERS = 6371008.8; const EARTH_RADIUS_METERS = 6371008.8;
function laneCenterline(lane) { function laneCenterline(lane) {
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null; 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; if (!Array.isArray(ring) || ring.length < 5 || !sameCoordinate(ring[0], ring.at(-1))) return null;
const vertices = ring.slice(0, -1); const vertices = ring.slice(0, -1);
if (!vertices.every(validCoordinate)) return null; if (!vertices.every(validCoordinate)) return null;
const half = vertices.length / 2; const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null; if (!Number.isInteger(half) || half < 2) return null;
const centerline = vertices.slice(0, half).map((point, index) => [ const centerline = vertices
(point[0] + vertices[vertices.length - 1 - index][0]) / 2, .slice(0, half)
(point[1] + vertices[vertices.length - 1 - index][1]) / 2, .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; return polylineLength(centerline) > 0.01 ? centerline : null;
} }
@@ -82,7 +84,7 @@ function lateralOffsetFrom(polyline, point) {
const offsetY = py - dy * ratio; const offsetY = py - dy * ratio;
const distance = Math.hypot(offsetX, offsetY); const distance = Math.hypot(offsetX, offsetY);
if (!best || distance < best.distance) { if (!best || distance < best.distance) {
best = { distance, lateral: offsetX * dy / length - offsetY * dx / length }; best = { distance, lateral: (offsetX * dy) / length - (offsetY * dx) / length };
} }
} }
return best; return best;
@@ -145,7 +147,7 @@ function metersAt(latitude) {
} }
function degreesToRadians(value) { function degreesToRadians(value) {
return value * Math.PI / 180; return (value * Math.PI) / 180;
} }
module.exports = { module.exports = {

View File

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

View File

@@ -1,25 +1,25 @@
"use strict"; 'use strict';
const fs = require("fs"); const fs = require('fs');
const { parseOsm } = require("./osm"); const { parseOsm } = require('./osm');
const { const {
buildTrafficSignalFeatures, buildTrafficSignalFeatures,
buildTrafficSignalsFromFeatures, buildTrafficSignalsFromFeatures,
validateTrafficSignalFeatures, validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences, validateTrafficSignalSourceReferences,
} = require("./traffic-signals"); } = require('./traffic-signals');
const SCHEMA = "native-traffic-signals/v1"; const SCHEMA = 'native-traffic-signals/v1';
function loadOrGenerate(file, osmText, stopLines, intersections) { function loadOrGenerate(file, osmText, stopLines, intersections) {
if (fs.existsSync(file)) { if (fs.existsSync(file)) {
const document = JSON.parse(fs.readFileSync(file, "utf8")); const document = JSON.parse(fs.readFileSync(file, 'utf8'));
try { try {
return validateDocument(document, osmText); return validateDocument(document, osmText);
} catch (error) { } catch (error) {
// OSM edits can invalidate the stable identities in a document that was // OSM edits can invalidate the stable identities in a document that was
// itself generated from OSM. User-authored documents must remain strict. // itself generated from OSM. User-authored documents must remain strict.
if (document?.provenance === "generated:osm-controls" && isStaleSourceReferenceError(error)) { if (document?.provenance === 'generated:osm-controls' && isStaleSourceReferenceError(error)) {
return generate(osmText, stopLines, intersections); return generate(osmText, stopLines, intersections);
} }
throw error; throw error;
@@ -29,21 +29,32 @@ function loadOrGenerate(file, osmText, stopLines, intersections) {
} }
function isStaleSourceReferenceError(error) { 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); 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) { function generate(osmText, stopLines, intersections) {
const controls = parseOsm(osmText).trafficSignalControls; const controls = parseOsm(osmText).trafficSignalControls;
return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) }; return {
schema: SCHEMA,
provenance: 'generated:osm-controls',
assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls),
};
} }
function validateDocument(value, osmText) { function validateDocument(value, osmText) {
if (value?.schema !== SCHEMA) throw new Error(`Expected ${SCHEMA} signal document`); if (value?.schema !== SCHEMA) throw new Error(`Expected ${SCHEMA} signal document`);
const assemblies = validateTrafficSignalFeatures(value.assemblies); const assemblies = validateTrafficSignalFeatures(value.assemblies);
if (osmText) validateTrafficSignalSourceReferences(assemblies, parseOsm(osmText).trafficSignalControls); if (osmText) validateTrafficSignalSourceReferences(assemblies, parseOsm(osmText).trafficSignalControls);
return { schema: SCHEMA, provenance: value.provenance || "native", assemblies }; return { schema: SCHEMA, provenance: value.provenance || 'native', assemblies };
} }
function runtime(document) { return buildTrafficSignalsFromFeatures(document.assemblies); } function runtime(document) {
return buildTrafficSignalsFromFeatures(document.assemblies);
}
module.exports = { SCHEMA, generate, loadOrGenerate, validateDocument, runtime }; module.exports = { SCHEMA, generate, loadOrGenerate, validateDocument, runtime };

View File

@@ -1,11 +1,13 @@
"use strict"; 'use strict';
function parseOsm(xml) { function parseOsm(xml) {
const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/); const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/);
const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {}; const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {};
const candidateBounds = { const candidateBounds = {
minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat), minLon: Number(boundsAttrs.minlon),
maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat), minLat: Number(boundsAttrs.minlat),
maxLon: Number(boundsAttrs.maxlon),
maxLat: Number(boundsAttrs.maxlat),
}; };
const bounds = Object.values(candidateBounds).every(Number.isFinite) ? candidateBounds : null; const bounds = Object.values(candidateBounds).every(Number.isFinite) ? candidateBounds : null;
const nodes = new Map(); const nodes = new Map();
@@ -13,19 +15,19 @@ function parseOsm(xml) {
const nodePattern = /<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g; const nodePattern = /<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g;
for (const match of xml.matchAll(nodePattern)) { for (const match of xml.matchAll(nodePattern)) {
const attrs = xmlAttrs(match[1]); const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue; if (attrs.action === 'delete' || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
const coordinate = [Number(attrs.lon), Number(attrs.lat)]; const coordinate = [Number(attrs.lon), Number(attrs.lat)];
if (!coordinate.every(Number.isFinite)) continue; if (!coordinate.every(Number.isFinite)) continue;
nodes.set(attrs.id, coordinate); nodes.set(attrs.id, coordinate);
const tags = parseTags(match[2] || ""); const tags = parseTags(match[2] || '');
if (tags.highway === "traffic_signals") { if (tags.highway === 'traffic_signals') {
trafficSignalControls.push({ id: attrs.id, longitude: coordinate[0], latitude: coordinate[1], tags }); trafficSignalControls.push({ id: attrs.id, longitude: coordinate[0], latitude: coordinate[1], tags });
} }
} }
const ways = []; const ways = [];
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) { for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
const attrs = xmlAttrs(match[1]); const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete") continue; if (attrs.action === 'delete') continue;
const body = match[2]; const body = match[2];
const refs = []; const refs = [];
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) { for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
@@ -53,22 +55,36 @@ function parseOsm(xml) {
} }
} }
control.arms = dedupeHeadings(arms); control.arms = dedupeHeadings(arms);
control.junctionType = control.arms.length === 3 ? "T" : control.arms.length === 4 ? "cross" : "other"; control.junctionType = control.arms.length === 3 ? 'T' : control.arms.length === 4 ? 'cross' : 'other';
} }
return { bounds, nodes, ways, trafficSignalControls }; return { bounds, nodes, ways, trafficSignalControls };
} }
function isMotorRoad(tags) { function isMotorRoad(tags) {
const highway = tags.highway || ""; const highway = tags.highway || '';
return highway && tags.area !== "yes" && !new Set([ return (
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track", highway &&
"bridleway", "corridor", "elevator", "platform", "construction", tags.area !== 'yes' &&
]).has(highway); !new Set([
'footway',
'path',
'pedestrian',
'steps',
'cycleway',
'service',
'track',
'bridleway',
'corridor',
'elevator',
'platform',
'construction',
]).has(highway)
);
} }
function headingBetween(from, to) { function headingBetween(from, to) {
const latitude = (from.latitude + to[1]) / 2 * Math.PI / 180; 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; return (Math.atan2((to[0] - from.longitude) * Math.cos(latitude), to[1] - from.latitude) * 180) / Math.PI;
} }
function dedupeHeadings(arms) { function dedupeHeadings(arms) {
@@ -94,7 +110,7 @@ function parseTags(body) {
const tags = {}; const tags = {};
for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) { for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
const tag = xmlAttrs(match[1]); const tag = xmlAttrs(match[1]);
if (tag.k) tags[tag.k] = tag.v || ""; if (tag.k) tags[tag.k] = tag.v || '';
} }
return tags; return tags;
} }

View File

@@ -1,6 +1,6 @@
"use strict"; 'use strict';
const fs = require("fs"); const fs = require('fs');
const PI = Math.PI; const PI = Math.PI;
const EARTH_A = 6378245.0; const EARTH_A = 6378245.0;
@@ -8,17 +8,17 @@ const EARTH_EE = 0.00669342162296594323;
function transformLat(x, y) { 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)); 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(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 += ((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; value += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
return value; return value;
} }
function transformLon(x, y) { 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)); 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(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 += ((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; value += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
return value; return value;
} }
@@ -27,33 +27,37 @@ function transformLon(x, y) {
// source coordinates remain WGS84. // source coordinates remain WGS84.
function gcj02ToWgs84(coordinate) { function gcj02ToWgs84(coordinate) {
const [longitude, latitude] = coordinate; const [longitude, latitude] = coordinate;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error("Reference coordinate must be finite"); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error('Reference coordinate must be finite');
const dLat = transformLat(longitude - 105, latitude - 35); const dLat = transformLat(longitude - 105, latitude - 35);
const dLon = transformLon(longitude - 105, latitude - 35); const dLon = transformLon(longitude - 105, latitude - 35);
const radLat = latitude / 180 * PI; const radLat = (latitude / 180) * PI;
const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2; const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2;
const sqrtMagic = Math.sqrt(magic); const sqrtMagic = Math.sqrt(magic);
return [ return [
longitude - dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI), longitude - (dLon * 180) / ((EARTH_A / sqrtMagic) * Math.cos(radLat) * PI),
latitude - dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI), latitude - (dLat * 180) / (((EARTH_A * (1 - EARTH_EE)) / (magic * sqrtMagic)) * PI),
]; ];
} }
function mapCoordinates(coordinates, mapper) { function mapCoordinates(coordinates, mapper) {
if (typeof coordinates[0] === "number") return mapper(coordinates); if (typeof coordinates[0] === 'number') return mapper(coordinates);
return coordinates.map((value) => mapCoordinates(value, mapper)); return coordinates.map((value) => mapCoordinates(value, mapper));
} }
function convertGeoJson(document) { function convertGeoJson(document) {
if (!document || document.type !== "FeatureCollection" || !Array.isArray(document.features)) { if (!document || document.type !== 'FeatureCollection' || !Array.isArray(document.features)) {
throw new Error("Reference must be a GeoJSON FeatureCollection"); throw new Error('Reference must be a GeoJSON FeatureCollection');
} }
return { return {
...document, ...document,
crs: undefined, crs: undefined,
features: document.features.map((feature) => { features: document.features.map((feature) => {
if (!feature || !feature.geometry || !feature.geometry.coordinates) throw new Error("Reference feature is missing geometry"); if (!feature || !feature.geometry || !feature.geometry.coordinates)
return { ...feature, geometry: { ...feature.geometry, coordinates: mapCoordinates(feature.geometry.coordinates, gcj02ToWgs84) } }; throw new Error('Reference feature is missing geometry');
return {
...feature,
geometry: { ...feature.geometry, coordinates: mapCoordinates(feature.geometry.coordinates, gcj02ToWgs84) },
};
}), }),
}; };
} }
@@ -66,7 +70,7 @@ function coordinatesOf(document) {
function walkCoordinates(value, points) { function walkCoordinates(value, points) {
if (!Array.isArray(value) || !value.length) return; if (!Array.isArray(value) || !value.length) return;
if (typeof value[0] === "number") { if (typeof value[0] === 'number') {
points.push(value); points.push(value);
return; return;
} }
@@ -75,7 +79,7 @@ function walkCoordinates(value, points) {
function boundsOf(document) { function boundsOf(document) {
const points = coordinatesOf(document); const points = coordinatesOf(document);
if (!points.length) throw new Error("Reference contains no coordinates"); if (!points.length) throw new Error('Reference contains no coordinates');
return { return {
minLon: Math.min(...points.map((point) => point[0])), minLon: Math.min(...points.map((point) => point[0])),
minLat: Math.min(...points.map((point) => point[1])), minLat: Math.min(...points.map((point) => point[1])),
@@ -89,7 +93,7 @@ function centerOf(bounds) {
} }
function distanceMeters(first, second) { function distanceMeters(first, second) {
const lonScale = 111320 * Math.cos(first[1] * PI / 180); const lonScale = 111320 * Math.cos((first[1] * PI) / 180);
return Math.hypot((second[0] - first[0]) * lonScale, (second[1] - first[1]) * 111320); return Math.hypot((second[0] - first[0]) * lonScale, (second[1] - first[1]) * 111320);
} }
@@ -97,13 +101,15 @@ function parseOsmNodes(xml) {
const nodes = []; const nodes = [];
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) { for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
const attrs = {}; const attrs = {};
for (const item of match[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[item[1]] = item[2] ?? item[3]; 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; if (!attrs.id || !Number.isFinite(Number(attrs.lon)) || !Number.isFinite(Number(attrs.lat))) continue;
const tags = {}; const tags = {};
for (const item of (match[2] || "").matchAll(/<tag\b([^>]*)\/?\s*>/g)) { for (const item of (match[2] || '').matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
const tag = {}; const tag = {};
for (const attr of item[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) tag[attr[1]] = attr[2] ?? attr[3]; for (const attr of item[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
if (tag.k) tags[tag.k] = tag.v || ""; 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 }); nodes.push({ id: String(attrs.id), coordinate: [Number(attrs.lon), Number(attrs.lat)], tags });
} }
@@ -114,12 +120,12 @@ function nearestNode(nodes, coordinate, nodeId) {
if (nodeId) { if (nodeId) {
const exact = nodes.find((node) => node.id === String(nodeId)); const exact = nodes.find((node) => node.id === String(nodeId));
if (!exact) throw new Error(`OSM node not found: ${nodeId}`); if (!exact) throw new Error(`OSM node not found: ${nodeId}`);
return { ...exact, distanceMeters: distanceMeters(exact.coordinate, coordinate), match: "node-id" }; return { ...exact, distanceMeters: distanceMeters(exact.coordinate, coordinate), match: 'node-id' };
} }
const candidates = nodes.map((node) => ({ ...node, distanceMeters: distanceMeters(node.coordinate, coordinate) })); const candidates = nodes.map((node) => ({ ...node, distanceMeters: distanceMeters(node.coordinate, coordinate) }));
candidates.sort((first, second) => first.distanceMeters - second.distanceMeters); candidates.sort((first, second) => first.distanceMeters - second.distanceMeters);
if (!candidates[0]) throw new Error("OSM contains no usable nodes"); if (!candidates[0]) throw new Error('OSM contains no usable nodes');
return { ...candidates[0], match: "nearest-node" }; return { ...candidates[0], match: 'nearest-node' };
} }
function bboxIntersectionRatio(first, second) { function bboxIntersectionRatio(first, second) {
@@ -136,47 +142,73 @@ function bboxIntersectionRatio(first, second) {
// Match it by cluster id, or by whichever cluster core sits nearest the node. // Match it by cluster id, or by whichever cluster core sits nearest the node.
function complexClusterSurface(nativeRoadSurfaceFile, node, clusterId) { function complexClusterSurface(nativeRoadSurfaceFile, node, clusterId) {
if (!nativeRoadSurfaceFile || !fs.existsSync(nativeRoadSurfaceFile)) return null; if (!nativeRoadSurfaceFile || !fs.existsSync(nativeRoadSurfaceFile)) return null;
const surface = JSON.parse(fs.readFileSync(nativeRoadSurfaceFile, "utf8")); const surface = JSON.parse(fs.readFileSync(nativeRoadSurfaceFile, 'utf8'));
const parts = (surface.features || []).filter((item) => item.properties?.cluster_id && item.properties?.complex_part); 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)); const cores = parts.filter(
(item) => item.properties.complex_part === 'core' && Array.isArray(item.properties.center),
);
if (!cores.length) return null; if (!cores.length) return null;
const core = clusterId const core = clusterId
? cores.find((item) => String(item.properties.cluster_id) === String(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]; : [...cores].sort(
(first, second) =>
distanceMeters(first.properties.center, node.coordinate) -
distanceMeters(second.properties.center, node.coordinate),
)[0];
if (!core) return null; if (!core) return null;
const features = parts.filter((item) => item.properties.cluster_id === core.properties.cluster_id); const features = parts.filter((item) => item.properties.cluster_id === core.properties.cluster_id);
return { clusterId: core.properties.cluster_id, core, features }; return { clusterId: core.properties.cluster_id, core, features };
} }
function inspectReference({ referenceFile, osmFile, nativeIntersectionFile, nativeRoadSurfaceFile, nodeId, clusterId }) { function inspectReference({
const source = JSON.parse(fs.readFileSync(referenceFile, "utf8")); referenceFile,
osmFile,
nativeIntersectionFile,
nativeRoadSurfaceFile,
nodeId,
clusterId,
}) {
const source = JSON.parse(fs.readFileSync(referenceFile, 'utf8'));
const converted = convertGeoJson(source); const converted = convertGeoJson(source);
const referenceBounds = boundsOf(converted); const referenceBounds = boundsOf(converted);
const referenceCenter = centerOf(referenceBounds); const referenceCenter = centerOf(referenceBounds);
const nodes = parseOsmNodes(fs.readFileSync(osmFile, "utf8")); const nodes = parseOsmNodes(fs.readFileSync(osmFile, 'utf8'));
const matchedNode = nearestNode(nodes, referenceCenter, nodeId); const matchedNode = nearestNode(nodes, referenceCenter, nodeId);
const native = JSON.parse(fs.readFileSync(nativeIntersectionFile, "utf8")); const native = JSON.parse(fs.readFileSync(nativeIntersectionFile, 'utf8'));
const feature = (native.features || []).find((item) => item.properties?.osm_node_id === matchedNode.id); const feature = (native.features || []).find((item) => item.properties?.osm_node_id === matchedNode.id);
const cluster = feature ? null : complexClusterSurface(nativeRoadSurfaceFile, matchedNode, clusterId); const cluster = feature ? null : complexClusterSurface(nativeRoadSurfaceFile, matchedNode, clusterId);
const matchedFeatures = feature ? [feature] : cluster?.features || null; const matchedFeatures = feature ? [feature] : cluster?.features || null;
const nativeBounds = matchedFeatures ? boundsOf({ features: matchedFeatures }) : null; const nativeBounds = matchedFeatures ? boundsOf({ features: matchedFeatures }) : null;
const diagnostics = []; 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"); 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 { return {
schema: "gaode-junction-reference-comparison/v2", schema: 'gaode-junction-reference-comparison/v2',
source: { file: referenceFile, coordinateSystem: "GCJ-02", featureCount: converted.features.length }, source: { file: referenceFile, coordinateSystem: 'GCJ-02', featureCount: converted.features.length },
conversion: { target: "WGS84", method: "gcj02-inverse-approximation" }, conversion: { target: 'WGS84', method: 'gcj02-inverse-approximation' },
reference: { bounds: referenceBounds, center: referenceCenter }, reference: { bounds: referenceBounds, center: referenceCenter },
matchedOsmNode: { id: matchedNode.id, coordinate: matchedNode.coordinate, tags: matchedNode.tags, match: matchedNode.match, centerDistanceMeters: matchedNode.distanceMeters }, matchedOsmNode: {
nativeIntersection: nativeBounds ? { id: matchedNode.id,
kind: feature ? "junction-node" : "complex-cluster", coordinate: matchedNode.coordinate,
clusterId: cluster?.clusterId || null, tags: matchedNode.tags,
featureCount: matchedFeatures.length, match: matchedNode.match,
bounds: nativeBounds, centerDistanceMeters: matchedNode.distanceMeters,
bboxIoU: bboxIntersectionRatio(referenceBounds, nativeBounds), },
centerOffsetMeters: distanceMeters(referenceCenter, centerOf(nativeBounds)), nativeIntersection: nativeBounds
featureProperties: feature ? feature.properties : cluster.core.properties, ? {
} : null, 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, diagnostics,
converted, converted,
matchedFeatures, matchedFeatures,
@@ -186,10 +218,10 @@ function inspectReference({ referenceFile, osmFile, nativeIntersectionFile, nati
function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters = 180 }) { function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters = 180 }) {
const width = 1000; const width = 1000;
const height = 1000; const height = 1000;
const lonScale = 111320 * Math.cos(center[1] * PI / 180); const lonScale = 111320 * Math.cos((center[1] * PI) / 180);
const project = (point) => [ const project = (point) => [
width / 2 + (point[0] - center[0]) * lonScale * width / (radiusMeters * 2), width / 2 + ((point[0] - center[0]) * lonScale * width) / (radiusMeters * 2),
height / 2 - (point[1] - center[1]) * 111320 * height / (radiusMeters * 2), height / 2 - ((point[1] - center[1]) * 111320 * height) / (radiusMeters * 2),
]; ];
const pathFor = (coordinates) => { const pathFor = (coordinates) => {
const parts = []; const parts = [];
@@ -201,23 +233,30 @@ function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters
const [x, y] = project(point); const [x, y] = project(point);
parts.push(`L ${x.toFixed(1)} ${y.toFixed(1)}`); parts.push(`L ${x.toFixed(1)} ${y.toFixed(1)}`);
} }
if (close) parts.push("Z"); if (close) parts.push('Z');
}; };
const visit = (value) => { const visit = (value) => {
if (!Array.isArray(value) || !value.length) return; if (!Array.isArray(value) || !value.length) return;
if (typeof value[0] === "number") return; if (typeof value[0] === 'number') return;
if (typeof value[0][0] === "number") appendLine(value, value.length > 2); if (typeof value[0][0] === 'number') appendLine(value, value.length > 2);
else value.forEach(visit); else value.forEach(visit);
}; };
visit(coordinates); visit(coordinates);
return parts.join(" "); return parts.join(' ');
}; };
const color = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" }; const color = { 1: '#2563eb', 2: '#0f766e', 3: '#7c3aed', 4: '#ea580c', 5: '#64748b' };
const references = converted.features.map((feature) => { const references = converted.features
const type = feature.properties?.type || "unknown"; .map((feature) => {
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"/>`; const type = feature.properties?.type || 'unknown';
}).join("\n"); 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"/>`;
const nativePaths = (nativeIntersection?.features || []).map((feature) => `<path d="${pathFor(feature.geometry.coordinates)}" fill="#dc262655" stroke="#dc2626" stroke-width="3"/>`).join("\n"); })
.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"?> 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}"> <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#f8fafc"/> <rect width="100%" height="100%" fill="#f8fafc"/>
@@ -228,4 +267,12 @@ function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters
</svg>`; </svg>`;
} }
module.exports = { gcj02ToWgs84, convertGeoJson, boundsOf, parseOsmNodes, nearestNode, inspectReference, localReferenceSvg }; module.exports = {
gcj02ToWgs84,
convertGeoJson,
boundsOf,
parseOsmNodes,
nearestNode,
inspectReference,
localReferenceSvg,
};

View File

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

View File

@@ -101,8 +101,5 @@
"bytes": 15491 "bytes": 15491
} }
}, },
"volatileExcluded": [ "volatileExcluded": ["absolute paths -> <repo> or <external>", "native-road-* staging directory -> <staging>"]
"absolute paths -> <repo> or <external>",
"native-road-* staging directory -> <staging>"
]
} }

View File

@@ -101,8 +101,5 @@
"bytes": 74151 "bytes": 74151
} }
}, },
"volatileExcluded": [ "volatileExcluded": ["absolute paths -> <repo> or <external>", "native-road-* staging directory -> <staging>"]
"absolute paths -> <repo> or <external>",
"native-road-* staging directory -> <staging>"
]
} }

View File

@@ -1,49 +1,61 @@
"use strict"; 'use strict';
const assert = require("assert/strict"); const assert = require('assert/strict');
const crypto = require("crypto"); const crypto = require('crypto');
const { execFileSync } = require("child_process"); const { execFileSync } = require('child_process');
const fs = require("fs"); const fs = require('fs');
const path = require("path"); const path = require('path');
const { unzipSync, strFromU8 } = require("fflate"); const { unzipSync, strFromU8 } = require('fflate');
const { compiler } = require("../src"); const { compiler } = require('../src');
const { exportNativeRoadPackage } = require("../src/export/native-road-package"); const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const root = path.resolve(__dirname, ".."); const root = path.resolve(__dirname, '..');
for (const areaId of ["fengshu-er-road", "nantaizi-lake-innovation-valley"]) { for (const areaId of ['fengshu-er-road', 'nantaizi-lake-innovation-valley']) {
const isFengshu = areaId === "fengshu-er-road"; const isFengshu = areaId === 'fengshu-er-road';
const outputRoot = path.join(root, "outputs", areaId); const outputRoot = path.join(root, 'outputs', areaId);
const input = { const input = {
areaId, areaId,
osmFile: path.join(root, "inputs", "osm", isFengshu ? "枫树二路.osm" : "南台子湖创新谷OSM.osm"), osmFile: path.join(root, 'inputs', 'osm', isFengshu ? '枫树二路.osm' : '南台子湖创新谷OSM.osm'),
outDir: path.join(outputRoot, "native-road"), outDir: path.join(outputRoot, 'native-road'),
stagingDir: path.join(outputRoot, "_pipeline"), stagingDir: path.join(outputRoot, '_pipeline'),
overridesFile: path.join(outputRoot, "native-road-overrides.json"), overridesFile: path.join(outputRoot, 'native-road-overrides.json'),
trafficSignalsFile: path.join(outputRoot, "native-traffic-signals.json"), trafficSignalsFile: path.join(outputRoot, 'native-traffic-signals.json'),
comparisonDir: path.join(outputRoot, "osm2streets_web_out"), comparisonDir: path.join(outputRoot, 'osm2streets_web_out'),
options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } }, options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } },
}; };
assert.ok(fs.existsSync(path.join(root, "test", "baseline", `${areaId}.json`))); assert.ok(fs.existsSync(path.join(root, 'test', 'baseline', `${areaId}.json`)));
const { result } = compiler.compileInput(input); const { result } = compiler.compileInput(input);
assert.equal(result.areaId, areaId); assert.equal(result.areaId, areaId);
assert.ok(fs.existsSync(path.join(input.outDir, "compiled.json"))); assert.ok(fs.existsSync(path.join(input.outDir, 'compiled.json')));
const first = exportNativeRoadPackage(input.outDir); const first = exportNativeRoadPackage(input.outDir);
const second = exportNativeRoadPackage(input.outDir); const second = exportNativeRoadPackage(input.outDir);
assert.equal(crypto.createHash("sha256").update(first.bytes).digest("hex"), crypto.createHash("sha256").update(second.bytes).digest("hex")); assert.equal(
crypto.createHash('sha256').update(first.bytes).digest('hex'),
crypto.createHash('sha256').update(second.bytes).digest('hex'),
);
const entries = unzipSync(first.bytes); const entries = unzipSync(first.bytes);
const manifest = JSON.parse(strFromU8(entries["manifest.json"])); const manifest = JSON.parse(strFromU8(entries['manifest.json']));
assert.equal(manifest.areaId, areaId); assert.equal(manifest.areaId, areaId);
assert.equal(manifest.contract, "native-road-package/v1.1"); assert.equal(manifest.contract, 'native-road-package/v1.1');
assert.deepEqual(manifest.generator, { name: "road-compiler", version: "0.3.0" }); assert.deepEqual(manifest.generator, { name: 'road-compiler', version: '0.3.0' });
assert.equal("source" in JSON.parse(strFromU8(entries["compiled.json"])), false); assert.equal('source' in JSON.parse(strFromU8(entries['compiled.json'])), false);
assert.equal(Object.keys(entries).some((entry) => entry.includes("override") || entry.endsWith(".osm")), false); assert.equal(
Object.keys(entries).some((entry) => entry.includes('override') || entry.endsWith('.osm')),
false,
);
assert.equal(Object.keys(entries).length, 18); assert.equal(Object.keys(entries).length, 18);
if (isFengshu) { if (isFengshu) {
const inputFile = path.join(outputRoot, "road-compiler-input.json"); const inputFile = path.join(outputRoot, 'road-compiler-input.json');
const archive = path.join(outputRoot, "export.zip"); const archive = path.join(outputRoot, 'export.zip');
fs.writeFileSync(inputFile, `${JSON.stringify(input)}\n`); fs.writeFileSync(inputFile, `${JSON.stringify(input)}\n`);
execFileSync(process.execPath, [path.join(root, "bin", "road-compiler.js"), "--input", inputFile, "--export-zip", archive]); execFileSync(process.execPath, [
path.join(root, 'bin', 'road-compiler.js'),
'--input',
inputFile,
'--export-zip',
archive,
]);
assert.ok(fs.existsSync(archive)); assert.ok(fs.existsSync(archive));
} }
} }
console.log("road compiler fixture tests passed"); console.log('road compiler fixture tests passed');

File diff suppressed because one or more lines are too long

View File

@@ -1,25 +1,31 @@
"use strict"; 'use strict';
const assert = require("assert/strict"); const assert = require('assert/strict');
const fs = require("fs"); const fs = require('fs');
const path = require("path"); const path = require('path');
const compiler = require("../src"); const compiler = require('../src');
assert.equal(typeof compiler.laneGeometry.laneCenterline, "function"); assert.equal(typeof compiler.laneGeometry.laneCenterline, 'function');
assert.equal(typeof compiler.gaodeReference.convertGeoJson, "function"); assert.equal(typeof compiler.gaodeReference.convertGeoJson, 'function');
assert.equal(typeof compiler.turnLaneArrows.buildCustomTurnLaneArrows, "function"); assert.equal(typeof compiler.turnLaneArrows.buildCustomTurnLaneArrows, 'function');
assert.equal(typeof compiler.complexJunction.buildComplexJunctionGeometry, "function"); assert.equal(typeof compiler.complexJunction.buildComplexJunctionGeometry, 'function');
assert.equal(typeof compiler.nativeRoad.compileRoadModel, "function"); assert.equal(typeof compiler.nativeRoad.compileRoadModel, 'function');
assert.equal(typeof compiler.check.checkOutput, "function"); assert.equal(typeof compiler.check.checkOutput, 'function');
assert.equal(typeof compiler.layerManifest.manifestForArea, "function"); assert.equal(typeof compiler.layerManifest.manifestForArea, 'function');
const manifest = compiler.layerManifest.manifestForArea("fixture"); const manifest = compiler.layerManifest.manifestForArea('fixture');
assert.equal(manifest.contract, "native-road-package/v1.1"); assert.equal(manifest.contract, 'native-road-package/v1.1');
assert.equal(manifest.layers.length, 12); assert.equal(manifest.layers.length, 12);
assert.deepEqual(manifest.layers.filter((layer) => layer.role === "semantic").map((layer) => layer.source), ["lane_centerlines", "connectors"]); assert.deepEqual(
assert.equal(manifest.layers.find((layer) => layer.source === "center_lines").splitBy.cases[0].match, "white"); manifest.layers.filter((layer) => layer.role === 'semantic').map((layer) => layer.source),
const fixture = path.join(__dirname, "fixtures", "fengshu-er-road.osm"); ['lane_centerlines', 'connectors'],
);
assert.equal(manifest.layers.find((layer) => layer.source === 'center_lines').splitBy.cases[0].match, 'white');
const fixture = path.join(__dirname, 'fixtures', 'fengshu-er-road.osm');
assert.ok(fs.existsSync(fixture)); assert.ok(fs.existsSync(fixture));
assert.throws(() => compiler.compiler.validateInput({ id: "area" }), /RoadCompilerInput/); assert.throws(() => compiler.compiler.validateInput({ id: 'area' }), /RoadCompilerInput/);
const model = compiler.nativeRoad.compileRoadModel(fs.readFileSync(fixture, "utf8"), { schema: "native-road-overrides/v1", overrides: [] }); const model = compiler.nativeRoad.compileRoadModel(fs.readFileSync(fixture, 'utf8'), {
schema: 'native-road-overrides/v1',
overrides: [],
});
assert.equal(model.roads.length, 24); assert.equal(model.roads.length, 24);
console.log("road compiler package tests passed"); console.log('road compiler package tests passed');

View File

@@ -1 +1,223 @@
*{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}} * {
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: 0.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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,12 @@
<!doctype html> <!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> <html lang="zh-CN">
<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><a id="export-package" href="/api/export.zip" download>导出道路包</a><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header> <head>
<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> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>道路编译工作台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,638 @@
import { useEffect, useState } from 'react';
import { Download, RefreshCw, Save, Upload } from 'lucide-react';
import { api } from './lib/api';
import { MapCanvas } from './components/MapCanvas';
import { Button } from './ui/button';
import type { GeoFeature, Override, Road, WorkbenchState } from './types/state';
import type { LayerName } from './map/layers';
const layerLabels: Array<[LayerName, string]> = [
['osm', 'OSM 道路中心线'],
['native', '自研道路与路口面'],
['sidewalks', '路缘与步行带'],
['lanes', '车道与转向路径'],
['directionArrows', '道路方向箭头'],
['markings', '车道分隔线与路口转向箭头'],
['centerLines', '道路中心线'],
['edgeLines', '道路外缘线'],
['controls', '斑马线与停止线'],
['signals', '红绿灯设施'],
['gaodeReference', '高德规整路口参考'],
['reference', 'osm2streets 参考面'],
];
const defaultLayers = Object.fromEntries(
layerLabels.map(([key]) => [key, key !== 'edgeLines' && key !== 'reference']),
) as Record<LayerName, boolean>;
const direction = (road: Road) => (road.direction === 'forward' ? '沿 OSM 方向' : '逆 OSM 方向');
const label = (road: Road) => road.tags.name || `${road.highway}OSM ${road.osmWayIds.join(', ')}`;
const laneIndex = (id: string) => Number(id.split(':').at(-1));
function App() {
const [state, setState] = useState<WorkbenchState>();
const [selected, setSelected] = useState<Road | null>(null);
const [staged, setStaged] = useState<Override[]>([]);
const [layers, setLayers] = useState(defaultLayers);
const [scene, setScene] = useState(false);
const [status, setStatus] = useState('正在加载工作台...');
const [filter, setFilter] = useState<'all' | 'candidates' | 'other'>('all');
const [marking, setMarking] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
api
.state()
.then((value) => {
if ('compiled' in value) {
setState(value);
setStatus(`已加载 ${value.compiled.model.roads.length} 条方向道路`);
} else setStatus('请导入 OSM 文件以开始');
})
.catch((error: Error) => setStatus(error.message));
}, []);
const stage = (change: Override) =>
setStaged((current) => [...current.filter((item) => item.id !== change.id), change]);
const save = async () => {
if (!state || !staged.length) return true;
try {
const overrides = [
...state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)),
...staged,
];
const result = await api.overrides(overrides);
setState({ ...state, overrides: result.overrides });
setStaged([]);
setStatus('已保存,点击“保存并重新生成”写入几何');
return true;
} catch (error) {
setStatus((error as Error).message);
return false;
}
};
const compile = async () => {
if (!(await save())) return;
try {
setStatus('正在重新生成...');
const next = await api.compile();
setState(next);
setSelected((current) => next.compiled.model.roads.find((road) => road.id === current?.id) || null);
setStatus('已保存并重新生成');
} catch (error) {
setStatus((error as Error).message);
}
};
if (!state)
return (
<ImportScreen
status={status}
onImported={(next) => {
setState(next);
setStatus(`已导入 ${next.areaId}`);
}}
/>
);
const diagnostics = state.compiled.diagnostics.filter(
(item) =>
item.rule !== 'ordinary-junction-surface' &&
(filter === 'all' || filter === 'candidates'
? Boolean(item.manualCandidates?.length)
: !item.manualCandidates?.length),
);
const handleFeature = (properties: Record<string, unknown>) => {
setMarking(properties);
const uid = properties.signal_uid;
if (typeof uid === 'string') setStatus(`已选中原生红绿灯 ${uid}`);
else if (properties.effective_style) setStatus('已选中标线,可在右侧暂存样式');
};
return (
<>
<header>
<strong></strong>
<span>{state.areaId}</span>
<span>{status}</span>
<span className={staged.length ? 'dirty' : ''}>
{staged.length ? `未保存修改 ${staged.length}` : '所有修改已保存'}
</span>
<label className="scene">
<input type="checkbox" checked={scene} onChange={(event) => setScene(event.target.checked)} />
</label>
<a className="button" href="/api/export.zip" download>
<Download size={15} />
</a>
<Button onClick={() => void save()} disabled={!staged.length}>
<Save size={15} />
</Button>
<Button onClick={() => void compile()}>
<RefreshCw size={15} />
</Button>
</header>
<main>
<aside className="issues">
<h1></h1>
{layerLabels.map(([key, text]) => (
<label key={key}>
<input
type="checkbox"
checked={layers[key]}
onChange={(event) => setLayers({ ...layers, [key]: event.target.checked })}
/>{' '}
{text}
</label>
))}
<hr />
<h1></h1>
<dl>
{Object.entries(state.comparison).map(([key, value]) => (
<>
<dt key={`${key}-t`}>{key}</dt>
<dd key={`${key}-d`}>{String(value)}</dd>
</>
))}
</dl>
<hr />
<h1></h1>
<div className="segmented">
{(['all', 'candidates', 'other'] as const).map((kind) => (
<Button className={filter === kind ? 'active' : ''} onClick={() => setFilter(kind)} key={kind}>
{kind === 'all' ? '全部' : kind === 'candidates' ? '可连接' : '其他'}
</Button>
))}
</div>
<ul>
{diagnostics.map((item) => (
<li key={item.id}>
<Button
onClick={() => {
const road = state.compiled.model.roads.find((itemRoad) => itemRoad.id === item.subjectId);
if (road) setSelected(road);
setStatus(item.message);
}}
>
{item.message}
</Button>
</li>
))}
</ul>
</aside>
<MapCanvas
state={state}
selected={selected}
visible={layers}
scene={scene}
onSelectRoad={setSelected}
onFeature={handleFeature}
/>
<Inspector
road={selected}
state={state}
staged={staged}
marking={marking}
stage={stage}
status={setStatus}
setState={setState}
/>
</main>
</>
);
}
function ImportScreen({ status, onImported }: { status: string; onImported: (state: WorkbenchState) => void }) {
const [file, setFile] = useState<File>();
const [busy, setBusy] = useState(false);
return (
<section className="import-screen">
<form
onSubmit={async (event) => {
event.preventDefault();
if (!file) return;
setBusy(true);
try {
onImported(await api.import(file));
} catch (error) {
console.error(error);
} finally {
setBusy(false);
}
}}
>
<h1> OSM </h1>
<p>{status}</p>
<input
type="file"
accept=".osm,application/xml,text/xml"
onChange={(event) => setFile(event.target.files?.[0])}
required
/>
<Button type="submit" disabled={!file || busy}>
<Upload size={16} /> {busy ? '正在导入并编译...' : '导入并编译'}
</Button>
</form>
</section>
);
}
function Inspector({
road,
state,
staged,
marking,
stage,
status,
setState,
}: {
road: Road | null;
state: WorkbenchState;
staged: Override[];
marking: Record<string, unknown> | null;
stage: (item: Override) => void;
status: (value: string) => void;
setState: (next: WorkbenchState) => void;
}) {
const [width, setWidth] = useState(0);
const [lanes, setLanes] = useState(1);
const [left, setLeft] = useState(false);
const [right, setRight] = useState(false);
useEffect(() => {
if (road) {
setWidth(road.widthMeters);
setLanes(road.laneCount);
setLeft(road.sidewalkLeft);
setRight(road.sidewalkRight);
}
}, [road]);
const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road?.id && item.side === 'end');
const movements = state.compiled.movements?.filter((item) => item.fromRoadId === road?.id) || [];
const effective = (id: string, fallback: boolean) =>
((staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id))?.enabled as
boolean | undefined) ?? fallback;
const updateSignal = async (features: GeoFeature[]) => {
try {
const result = await api.signals({
...state.trafficSignals,
assemblies: { ...state.trafficSignals.assemblies, features },
});
setState({ ...state, trafficSignals: result.trafficSignals, trafficRuntime: result.trafficRuntime });
status('红绿灯已保存');
} catch (error) {
status((error as Error).message);
}
};
const signalUid = typeof marking?.signal_uid === 'string' ? marking.signal_uid : '';
const signal = state.trafficSignals.assemblies.features.find((item) => item.properties?.signal_uid === signalUid);
return (
<aside className="inspector">
<h1></h1>
{road ? (
<form
onSubmit={(event) => {
event.preventDefault();
stage({
id: `道路:${road.id}`,
kind: 'road',
roadId: road.id,
widthMeters: width,
laneCount: lanes,
sidewalkLeft: left,
sidewalkRight: right,
});
const opposite = state.compiled.model.roads.find(
(item) => item.id !== road.id && item.segmentId === road.segmentId,
);
if (opposite)
stage({
id: `道路:${opposite.id}`,
kind: 'road',
roadId: opposite.id,
sidewalkLeft: right,
sidewalkRight: left,
});
status('有未保存修改');
}}
>
<label></label>
<output>
{label(road)}{direction(road)}
</output>
<output>
{movements.length
? `已识别 ${movements.length} 个行驶动作,${movements.filter((item) => item.geometryPublished).length} 条已绘制路径`
: '当前方向没有已识别的行驶动作'}
</output>
<label>
<input
type="number"
min="1"
step="0.01"
value={width}
onChange={(event) => setWidth(Number(event.target.value))}
/>
</label>
<label>
<input
type="number"
min="1"
step="1"
value={lanes}
onChange={(event) => setLanes(Number(event.target.value))}
/>
</label>
<label>
<input type="checkbox" checked={left} onChange={(event) => setLeft(event.target.checked)} />{' '}
</label>
<label>
<input type="checkbox" checked={right} onChange={(event) => setRight(event.target.checked)} />{' '}
</label>
<Button type="submit"></Button>
</form>
) : (
<p>线</p>
)}
<hr />
<h2></h2>
{road &&
state.compiled.model.connections
.filter((connection) => connection.fromEndpointId === endpoint?.id)
.map((connection) => (
<label key={connection.id}>
<input
type="checkbox"
checked={effective(`连接:${connection.id}`, connection.enabled)}
onChange={(event) =>
stage({
id: `连接:${connection.id}`,
kind: 'junction-connection',
fromEndpointId: connection.fromEndpointId,
toEndpointId: connection.toEndpointId,
enabled: event.target.checked,
})
}
/>{' '}
{connection.id}
</label>
))}
{movements.map((movement) => (
<label key={movement.id}>
<input
type="checkbox"
checked={effective(`车道连接:${movement.fromLaneId}->${movement.toLaneId}`, true)}
onChange={(event) =>
stage({
id: `车道连接:${movement.fromLaneId}->${movement.toLaneId}`,
kind: 'lane-connection',
fromLaneId: movement.fromLaneId,
toLaneId: movement.toLaneId,
enabled: event.target.checked,
})
}
/>{' '}
{movement.turn} {laneIndex(movement.fromLaneId)} {laneIndex(movement.toLaneId)}
{movement.geometryStatus || 'connector'}
</label>
))}
{endpoint &&
state.compiled.diagnostics
.find((item) => item.endpointId === endpoint.id)
?.manualCandidates?.map((candidate) => {
const target = state.compiled.model.roads.find((item) => item.id === candidate.roadId);
const targetEndpoint = state.compiled.model.endpoints.find(
(item) => item.roadId === candidate.roadId && item.side === 'start',
);
return target && targetEndpoint ? (
<Button
key={candidate.roadId}
onClick={() => {
stage({
id: `连接:connection:${endpoint.id}:${targetEndpoint.id}`,
kind: 'junction-connection',
fromEndpointId: endpoint.id,
toEndpointId: targetEndpoint.id,
enabled: true,
});
status('手工连接已暂存;保存并重新生成后会出现转向路径');
}}
>
{label(target)}{candidate.distanceMeters}
</Button>
) : null;
})}
<MarkingEditor marking={marking} stage={stage} />
<SignalEditor state={state} signal={signal} status={status} update={updateSignal} />
<Candidates state={state} setState={setState} status={status} />
<details>
<summary></summary>
<pre>
{JSON.stringify(
road ? { OSM道路: road.osmWayIds, 当前方向节点顺序: road.sourceNodeIds, 原始标签: road.tags } : marking,
null,
2,
)}
</pre>
</details>
</aside>
);
}
function MarkingEditor({
marking,
stage,
}: {
marking: Record<string, unknown> | null;
stage: (item: Override) => void;
}) {
const style = typeof marking?.effective_style === 'string' ? marking.effective_style : '';
const [value, setValue] = useState(style || 'yellow-dashed');
useEffect(() => setValue(style || 'yellow-dashed'), [style]);
if (!style || !marking) return null;
const roadId = String(marking.road_id);
const segment = marking.segment_id;
const kind = String(marking.provenance || '');
const save = () => {
const [color, pattern] = value.replace('double-', '').split('-');
if (segment)
stage({
id: `道路中心线:${segment}`,
kind: 'center-line-style',
segmentId: segment,
color,
pattern,
double: value.startsWith('double-'),
});
else if (kind.includes('edge-line'))
stage({
id: `道路外缘线:${roadId}:${marking.side}`,
kind: 'edge-line-style',
roadId,
side: marking.side,
color,
pattern,
});
else
stage({
id: `车道分隔线:${roadId}:${marking.left_lane_index}-${marking.right_lane_index}`,
kind: 'lane-separator-style',
roadId,
leftLaneIndex: marking.left_lane_index,
rightLaneIndex: marking.right_lane_index,
color,
pattern,
});
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
save();
}}
>
<hr />
<h2>线</h2>
<label>
<select
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
>
<option value="yellow-dashed">线</option>
<option value="white-dashed">线</option>
<option value="yellow-solid">线</option>
<option value="white-solid">线</option>
{segment ? <option value="double-yellow-solid">线</option> : null}
</select>
</label>
<Button type="submit">线</Button>
</form>
);
}
function SignalEditor({
state,
signal,
status,
update,
}: {
state: WorkbenchState;
signal?: GeoFeature;
status: (text: string) => void;
update: (features: GeoFeature[]) => Promise<void>;
}) {
const [selected, setSelected] = useState('');
useEffect(() => setSelected(String(signal?.properties?.signal_uid || '')), [signal]);
const feature = state.trafficSignals.assemblies.features.find((item) => item.properties?.signal_uid === selected);
const saveField = (key: string, value: unknown) => {
if (!feature) return;
void update(
state.trafficSignals.assemblies.features.map((item) =>
item === feature ? { ...item, properties: { ...item.properties, [key]: value } } : item,
),
);
};
return (
<section>
<hr />
<h2>绿</h2>
<Button
onClick={() =>
void api
.generateSignals()
.then((next) => {
void update(next.trafficSignals.assemblies.features);
status('已补充 OSM 信号灯');
})
.catch((error: Error) => status(error.message))
}
>
OSM
</Button>
<label>
<select value={selected} onChange={(event) => setSelected(event.target.value)}>
<option value=""></option>
{state.trafficSignals.assemblies.features.map((item) => (
<option key={String(item.properties?.signal_uid)} value={String(item.properties?.signal_uid)}>
{String(item.properties?.display_id || item.properties?.signal_uid)}
</option>
))}
</select>
</label>
{feature ? (
<>
<label>
<input
type="number"
value={Number(feature.properties?.mast_heading_deg || 0)}
onChange={(event) => saveField('mast_heading_deg', Number(event.target.value))}
/>
</label>
<label>
<input
type="number"
value={Number(feature.properties?.mast_reach_m || 0)}
onChange={(event) => saveField('mast_reach_m', Number(event.target.value))}
/>
</label>
<label>
<input
type="number"
value={Number(feature.properties?.face_heading_deg || 0)}
onChange={(event) => saveField('face_heading_deg', Number(event.target.value))}
/>
</label>
<label>
<input
type="checkbox"
checked={Boolean(feature.properties?.enabled)}
onChange={(event) => saveField('enabled', event.target.checked)}
/>{' '}
</label>
<Button
onClick={() =>
void update(state.trafficSignals.assemblies.features.filter((item) => item !== feature)).then(() => {
setSelected('');
status('红绿灯已删除');
})
}
>
</Button>
</>
) : null}
</section>
);
}
function Candidates({
state,
setState,
status,
}: {
state: WorkbenchState;
setState: (state: WorkbenchState) => void;
status: (text: string) => void;
}) {
const candidates = state.debug?.junctionCandidates || [];
if (!candidates.length) return null;
return (
<section>
<hr />
<h2>debug</h2>
{candidates.map((candidate) => (
<Button
key={candidate.id}
onClick={() =>
void api
.acceptJunctionCandidate(candidate.index)
.then((next) => {
setState(next);
status(`已加入 ${next.added.id} 并重新编译`);
})
.catch((error: Error) => status(error.message))
}
>
#{candidate.index}{candidate.nodeCount} {candidate.diameterMeters}
</Button>
))}
</section>
);
}
export default App;

View File

@@ -0,0 +1,88 @@
import { useEffect, useRef } from 'react';
import Map from 'ol/Map';
import View from 'ol/View';
import Select from 'ol/interaction/Select';
import { click } from 'ol/events/condition';
import type { Road, WorkbenchState } from '../types/state';
import { createLayers, updateLayers, type LayerName } from '../map/layers';
interface Props {
state: WorkbenchState;
selected: Road | null;
visible: Partial<Record<LayerName, boolean>>;
scene: boolean;
onSelectRoad: (road: Road) => void;
onFeature: (properties: Record<string, unknown>) => void;
}
export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFeature }: Props) {
const target = useRef<HTMLDivElement>(null);
const mapRef = useRef<Map | null>(null);
const layersRef = useRef<ReturnType<typeof createLayers> | null>(null);
const selectedRef = useRef<Road | null>(selected);
const stateRef = useRef(state);
selectedRef.current = selected;
stateRef.current = state;
useEffect(() => {
if (!target.current) return;
const layers = createLayers(
() => selectedRef.current,
() => scene,
);
layersRef.current = layers;
const map = new Map({
target: target.current,
layers: Object.values(layers),
view: new View({ center: [0, 0], zoom: 2 }),
});
mapRef.current = map;
const select = new Select({
condition: click,
layers: (layer) => layer !== layers.selectedRoad,
hitTolerance: 12,
style: null,
});
map.addInteraction(select);
const listener = ({ selected: values }: { selected: import('ol/Feature').default[] }) => {
const properties = values[0]?.getProperties();
if (!properties) return;
const roadId =
properties.road_id ||
properties.from_road_id ||
(typeof properties.from_lane_id === 'string'
? properties.from_lane_id.slice(5, properties.from_lane_id.lastIndexOf(':'))
: undefined);
const road = stateRef.current.compiled.model.roads.find((item) => item.id === roadId);
if (road) onSelectRoad(road);
onFeature(properties);
};
select.on('select', listener);
return () => {
select.un('select', listener);
map.setTarget(undefined);
};
// Map construction must be once per mounted canvas; state changes update sources below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const layers = layersRef.current;
const map = mapRef.current;
if (!layers || !map) return;
updateLayers(layers, state, selected);
const extent = layers.osm.getSource()!.getExtent();
if (extent && Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
}, [state, selected]);
useEffect(() => {
const layers = layersRef.current;
if (!layers) return;
Object.entries(visible).forEach(([name, enabled]) =>
layers[name as LayerName]?.setVisible(
!scene || !['osm', 'lanes', 'reference', 'gaodeReference', 'connectors'].includes(name)
? enabled !== false
: false,
),
);
layers.native.changed();
layers.sidewalks.changed();
}, [visible, scene]);
return <div className="map" ref={target} aria-label="道路地图" />;
}

View File

@@ -0,0 +1,40 @@
import type { WorkbenchState } from '../types/state';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, { cache: 'no-store', ...init });
const value = (await response.json()) as T & { ok?: boolean; error?: string };
if (!response.ok || value.ok === false) throw new Error(value.error || `HTTP ${response.status}`);
return value;
}
export const api = {
state: () => request<WorkbenchState | { active: false }>('/api/state'),
import: (file: File) => {
const body = new FormData();
body.append('file', file, file.name);
return request<WorkbenchState>('/api/import', { method: 'POST', body });
},
overrides: (overrides: unknown[]) =>
request<{ overrides: WorkbenchState['overrides'] }>('/api/overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema: 'native-road-overrides/v1', overrides }),
}),
compile: () => request<WorkbenchState>('/api/compile', { method: 'POST' }),
signals: (document: unknown) =>
request<Pick<WorkbenchState, 'trafficSignals' | 'trafficRuntime'>>('/api/traffic-signals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(document),
}),
generateSignals: () =>
request<Pick<WorkbenchState, 'trafficSignals' | 'trafficRuntime'>>('/api/traffic-signals/generate', {
method: 'POST',
}),
acceptJunctionCandidate: (index: number) =>
request<WorkbenchState & { added: { id: string } }>('/api/junction-clusters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index }),
}),
};

View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import 'ol/ol.css';
import './styles.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,160 @@
import Feature from 'ol/Feature';
import GeoJSON from 'ol/format/GeoJSON';
import LineString from 'ol/geom/LineString';
import Point from 'ol/geom/Point';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import CircleStyle from 'ol/style/Circle';
import Fill from 'ol/style/Fill';
import RegularShape from 'ol/style/RegularShape';
import Stroke from 'ol/style/Stroke';
import Style from 'ol/style/Style';
import type { Road, WorkbenchState } from '../types/state';
const geojson = new GeoJSON();
const source = () => new VectorSource();
export type LayerName =
| 'reference'
| 'gaodeReference'
| 'native'
| 'sidewalks'
| 'osm'
| 'lanes'
| 'edgeLines'
| 'directionArrows'
| 'markings'
| 'centerLines'
| 'controls'
| 'signals'
| 'connectors'
| 'diagnostics'
| 'selectedRoad';
export function createLayers(onRoad: () => Road | null, scene: () => boolean) {
const simple = (color: string, width = 1) =>
new Style({ fill: new Fill({ color: `${color}55` }), stroke: new Stroke({ color, width }) });
const layers: Record<LayerName, VectorLayer<VectorSource>> = {
reference: new VectorLayer({ source: source(), visible: false, style: simple('#8999a0') }),
gaodeReference: new VectorLayer({ source: source(), style: simple('#2563eb', 1.5) }),
native: new VectorLayer({
source: source(),
style: () => (scene() ? new Style({ fill: new Fill({ color: '#3f4b50' }) }) : simple('#296956')),
}),
sidewalks: new VectorLayer({
source: source(),
style: () => (scene() ? new Style({ fill: new Fill({ color: '#b7b9ad' }) }) : simple('#9b7c40')),
}),
osm: new VectorLayer({
source: source(),
style: (f) =>
new Style({
stroke: new Stroke({
color: f.get('road_id') === onRoad()?.id ? '#006e91' : '#263630',
width: f.get('road_id') === onRoad()?.id ? 5 : 2,
}),
}),
}),
lanes: new VectorLayer({
source: source(),
style: new Style({ stroke: new Stroke({ color: '#f5f6ee', width: 1.5, lineDash: [5, 4] }) }),
}),
edgeLines: new VectorLayer({
source: source(),
visible: false,
style: new Style({ stroke: new Stroke({ color: '#f5f6ee', width: 1 }) }),
}),
directionArrows: new VectorLayer({
source: source(),
style: new Style({ fill: new Fill({ color: '#f5f6ee' }), stroke: new Stroke({ color: '#d9dacf', width: 1 }) }),
}),
markings: new VectorLayer({
source: source(),
style: new Style({ fill: new Fill({ color: '#f5f6ee' }), stroke: new Stroke({ color: '#d9dacf', width: 1 }) }),
}),
centerLines: new VectorLayer({
source: source(),
style: new Style({ fill: new Fill({ color: '#f5be2a' }), stroke: new Stroke({ color: '#d29d16', width: 1 }) }),
}),
controls: new VectorLayer({
source: source(),
style: new Style({ fill: new Fill({ color: '#f5f6ee' }), stroke: new Stroke({ color: '#d9dacf', width: 1 }) }),
}),
signals: new VectorLayer({
source: source(),
style: 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 }),
}),
}),
}),
connectors: new VectorLayer({
source: source(),
style: new Style({ stroke: new Stroke({ color: '#ad3a76', width: 2, lineDash: [7, 5] }) }),
}),
diagnostics: new VectorLayer({
source: source(),
style: (f) =>
new Style({
image: new CircleStyle({
radius: 6,
fill: new Fill({ color: f.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 }) }),
}),
};
return layers;
}
function features(value: unknown) {
return geojson.readFeatures((value || { type: 'FeatureCollection', features: [] }) as object, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
}
export function updateLayers(layers: ReturnType<typeof createLayers>, state: WorkbenchState, selected: Road | null) {
const get = (name: LayerName) => layers[name].getSource()!;
const put = (name: LayerName, value: unknown) => {
const target = get(name);
target.clear();
target.addFeatures(features(value));
};
put('reference', state.layers.osm2streetsRoadSurface);
put('gaodeReference', state.junctionReference?.converted);
put('native', state.layers.nativeRoadSurface);
get('native').addFeatures(features(state.layers.nativeIntersectionSurface));
put('sidewalks', state.layers.nativeSidewalkSurface);
const roads = state.compiled.model.roads.map(
(road) =>
new Feature({ geometry: new LineString(road.centerline).transform('EPSG:4326', 'EPSG:3857'), road_id: road.id }),
);
put('osm', { type: 'FeatureCollection', features: [] });
get('osm').addFeatures(roads);
put('lanes', state.layers.laneCenterlines);
put('edgeLines', state.layers.edgeLines);
put('directionArrows', state.layers.directionArrows);
put('markings', state.layers.laneSeparators);
get('markings').addFeatures(features(state.layers.turnArrows));
put('centerLines', state.layers.centerLines);
put('controls', state.layers.crosswalks);
get('controls').addFeatures(features(state.layers.vehicleStopLines));
put('signals', state.trafficSignals.assemblies);
put('connectors', state.layers.connectors);
put('diagnostics', {
type: 'FeatureCollection',
features: state.compiled.diagnostics
.filter((item) => item.geometry)
.map(({ geometry, ...properties }) => ({ type: 'Feature', properties, geometry: geometry! })),
});
put('selectedRoad', { type: 'FeatureCollection', features: [] });
if (selected)
get('selectedRoad').addFeature(
new Feature({ geometry: new LineString(selected.centerline).transform('EPSG:4326', 'EPSG:3857') }),
);
}

View File

@@ -0,0 +1,223 @@
:root {
font-family: system-ui, sans-serif;
color: #202523;
background: #eef1ef;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
header {
height: 50px;
display: flex;
gap: 12px;
align-items: center;
padding: 0 16px;
background: #183a32;
color: white;
}
header span {
color: #c9d8d2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dirty {
color: #ffe08a;
font-weight: 700;
}
.scene {
margin-left: auto;
white-space: nowrap;
}
main {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 320px;
height: calc(100vh - 50px);
}
aside {
overflow: auto;
background: white;
padding: 16px;
}
.issues {
border-right: 1px solid #d5dfda;
}
.inspector {
border-left: 1px solid #d5dfda;
}
.map {
min-height: 400px;
background: #d7e2de;
}
.button {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #82988f;
background: white;
color: #1d392f;
padding: 7px 10px;
border-radius: 3px;
cursor: pointer;
text-decoration: none;
font: inherit;
}
.button:disabled {
opacity: 0.55;
cursor: default;
}
header .button {
white-space: nowrap;
}
h1 {
font-size: 16px;
margin: 0 0 8px;
}
h2 {
font-size: 14px;
margin: 12px 0 8px;
}
label,
output {
display: block;
margin: 10px 0;
}
input[type='number'] {
display: block;
width: 100%;
padding: 7px;
border: 1px solid #aab8b2;
}
dl {
display: grid;
grid-template-columns: 1fr auto;
gap: 5px 10px;
margin: 0;
}
dt {
color: #52615b;
}
dd {
margin: 0;
}
hr {
border: 0;
border-top: 1px solid #dde4e1;
margin: 16px 0;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
.issues li .button {
width: 100%;
text-align: left;
margin: 4px 0;
background: #fff7e5;
border-color: #e7c67b;
}
.segmented {
display: flex;
}
.segmented .button {
flex: 1;
border-radius: 0;
padding: 6px 4px;
}
.segmented .active {
background: #286956;
border-color: #286956;
color: white;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #52615b;
}
.import-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.import-screen form {
width: min(460px, 100%);
background: white;
padding: 28px;
box-shadow: 0 8px 30px #0003;
}
.import-screen input {
margin: 18px 0;
}
@media (max-width: 900px) {
header {
height: auto;
min-height: 50px;
flex-wrap: wrap;
padding-block: 8px;
}
main {
grid-template-columns: minmax(0, 1fr);
height: calc(100vh - 66px);
}
.issues {
display: none;
}
.inspector {
position: absolute;
right: 0;
bottom: 0;
width: min(360px, 100%);
max-height: 55vh;
border-top: 1px solid #d5dfda;
}
}
.overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.overview-grid div,
.overview-status,
.overview-diagnostics {
border: 1px solid #dce5e1;
padding: 7px;
background: #f8fbfa;
}
.overview-grid span,
.overview-status span,
.overview-diagnostics > span {
display: block;
color: #52615b;
font-size: 12px;
}
.overview-grid strong {
display: block;
margin-top: 2px;
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.overview-status {
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.overview-diagnostics {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.overview-diagnostics > span {
width: 100%;
}
.overview-diagnostics strong {
background: #fff4dc;
color: #7c5200;
padding: 2px 5px;
font-size: 12px;
}

View File

@@ -0,0 +1,92 @@
export type Position = [number, number];
export interface GeoFeature {
type: 'Feature';
properties?: Record<string, unknown>;
geometry: { type: string; coordinates: unknown } | null;
}
export interface GeoJson {
type: 'FeatureCollection';
features: GeoFeature[];
}
export interface Road {
id: string;
segmentId: string;
centerline: Position[];
sourceNodeIds: string[];
osmWayIds: string[];
tags: Record<string, string>;
highway: string;
direction: string;
widthMeters: number;
laneCount: number;
sidewalkLeft: boolean;
sidewalkRight: boolean;
provenance?: unknown;
appliedOverrideIds?: string[];
}
export interface Endpoint {
id: string;
roadId: string;
side: 'start' | 'end';
nodeId: string;
coordinate: Position;
}
export interface Connection {
id: string;
fromEndpointId: string;
toEndpointId: string;
enabled: boolean;
}
export interface Movement {
id: string;
connectionId: string;
fromRoadId: string;
toRoadId: string;
fromLaneId: string;
toLaneId: string;
turn: string;
geometryPublished?: boolean;
geometryStatus?: string;
nodeId?: string;
provenance?: string;
}
export interface Override {
id: string;
kind: string;
[key: string]: unknown;
}
export interface JunctionCandidate {
index: number;
id: string;
nodeIds: string[];
nodeCount: number;
diameterMeters: number;
template: string;
coreRadiusMeters: number;
}
export interface Diagnostic {
id: string;
message: string;
rule: string;
subjectId?: string;
endpointId?: string;
severity?: string;
geometry?: { type: string; coordinates: unknown };
manualCandidates?: Array<{ roadId: string; distanceMeters: number }>;
}
export interface WorkbenchState {
active?: boolean;
areaId: string;
compiled: {
model: { roads: Road[]; endpoints: Endpoint[]; connections: Connection[] };
movements?: Movement[];
diagnostics: Diagnostic[];
};
overrides: { schema: string; overrides: Override[] };
trafficSignals: { provenance: string; assemblies: GeoJson };
trafficRuntime: { signals: unknown[] };
comparison: Record<string, unknown>;
layers: Record<string, GeoJson | null>;
junctionReference?: { converted: GeoJson };
debug?: { junctionCandidates: JunctionCandidate[] } | null;
}

View File

@@ -0,0 +1,12 @@
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react';
export function Button({
children,
className = '',
...props
}: PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>) {
return (
<button className={`button ${className}`} {...props}>
{children}
</button>
);
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"skipLibCheck": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
export default defineConfig({
root: resolve(__dirname),
base: '/',
build: { outDir: resolve(__dirname, 'dist'), emptyOutDir: true },
server: { port: 5173, proxy: { '/api': 'http://127.0.0.1:8787' } },
});

View File

@@ -1,32 +1,57 @@
#!/usr/bin/env node #!/usr/bin/env node
"use strict"; 'use strict';
const fs = require("fs"); const fs = require('fs');
const http = require("http"); const http = require('http');
const path = require("path"); const path = require('path');
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../src/compile/native-road"); const { loadOverrides, validateOverrides, writeJsonAtomic } = require('../src/compile/native-road');
const { generate, validateDocument, runtime } = require("../src/native-traffic-signals"); const { generate, validateDocument, runtime } = require('../src/native-traffic-signals');
const { convertGeoJson } = require("../src/reference/gaode"); const { convertGeoJson } = require('../src/reference/gaode');
const { exportNativeRoadPackage } = require("../src/export/native-road-package"); const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const { compileInput } = require("../src/compile/compiler"); const { compileInput } = require('../src/compile/compiler');
function startWorkbench({ area = null, input = null, inputFile = null, repoRoot = process.cwd(), dataRoot = path.join(repoRoot, "workbench-data"), configPath = null, compileFresh = null, readAreaConfig = null, junctionReference = null, debug = false, port = 8787 }) { function startWorkbench({
if (typeof junctionReference === "string") junctionReference = readJunctionReference(junctionReference); area = null,
input = null,
inputFile = null,
repoRoot = process.cwd(),
dataRoot = path.join(repoRoot, 'workbench-data'),
configPath = null,
compileFresh = null,
readAreaConfig = null,
junctionReference = null,
debug = false,
port = 8787,
}) {
if (typeof junctionReference === 'string') junctionReference = readJunctionReference(junctionReference);
// `--debug` surfaces advisory compiler findings that have no geometry layer of // `--debug` surfaces advisory compiler findings that have no geometry layer of
// their own — currently the complex-junction candidates. Off by default so the // their own — currently the complex-junction candidates. Off by default so the
// normal editing view stays uncluttered. // normal editing view stays uncluttered.
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535]."); if (!Number.isInteger(port) || port < 1024 || port > 65535)
const session = { area, context: { repoRoot, configPath, compileFresh, readAreaConfig }, junctionReference, debug, dataRoot }; throw new Error('--port must be an integer in [1024, 65535].');
const session = {
area,
context: { repoRoot, configPath, compileFresh, readAreaConfig },
junctionReference,
debug,
dataRoot,
};
if (input) { if (input) {
session.context.compileFresh = () => { const compiled = compileInput(input); session.area = compiled.area; return compiled; }; session.context.compileFresh = () => {
const compiled = compileInput(input);
session.area = compiled.area;
return compiled;
};
session.context.compileFresh(); session.context.compileFresh();
} }
const server = http.createServer((request, response) => handle(request, response, session)); const server = http.createServer((request, response) => handle(request, response, session));
server.on("error", (error) => { server.on('error', (error) => {
console.error(`Road Workbench failed to listen: ${error.message}`); console.error(`Road Workbench failed to listen: ${error.message}`);
process.exitCode = 1; process.exitCode = 1;
}); });
server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? " (debug: 复杂路口候选已开启)" : ""}`)); server.listen(port, '127.0.0.1', () =>
console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? ' (debug: 复杂路口候选已开启)' : ''}`),
);
return server; return server;
} }
@@ -35,73 +60,152 @@ function handle(request, response, session) {
const context = session.context; const context = session.context;
const junctionReference = session.junctionReference; const junctionReference = session.junctionReference;
const debug = session.debug; const debug = session.debug;
const url = new URL(request.url, "http://127.0.0.1"); 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 === '/') return sendWorkbenchApp(response);
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.startsWith('/vendor/'))
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(__dirname, "client", "app.css"), "text/css; charset=utf-8"); return sendVendorFile(response, url.pathname, context.repoRoot);
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname, context.repoRoot); if (request.method === 'GET' && url.pathname === '/api/state')
if (request.method === "GET" && url.pathname === "/api/state") return area ? sendJson(response, 200, state(area, junctionReference, debug)) : sendJson(response, 200, { active: false }); return area
if (request.method === "GET" && url.pathname === "/api/session") return sendJson(response, 200, { active: Boolean(session.area), areaId: session.area?.id || null }); ? sendJson(response, 200, state(area, junctionReference, debug))
if (request.method === "POST" && url.pathname === "/api/import") return readUpload(request, session).then((result) => sendJson(response, 200, { ok: true, areaId: result.area.id, ...state(result.area, junctionReference, debug) })).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); : sendJson(response, 200, { active: false });
if (request.method === "GET" && url.pathname === "/api/export.zip") return Promise.resolve().then(() => { if (request.method === 'GET' && url.pathname === '/api/session')
if (!area) throw new Error("请先导入 OSM 文件。"); return sendJson(response, 200, { active: Boolean(session.area), areaId: session.area?.id || null });
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir); if (request.method === 'POST' && url.pathname === '/api/import')
response.writeHead(200, { return readUpload(request, session)
"Content-Type": "application/zip", .then((result) =>
"Content-Disposition": `attachment; filename="${area.id}.native-road.zip"`, sendJson(response, 200, { ok: true, areaId: result.area.id, ...state(result.area, junctionReference, debug) }),
"Content-Length": exported.bytes.length, )
"Cache-Control": "no-store", .catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
}); if (request.method === 'GET' && url.pathname === '/api/export.zip')
response.end(Buffer.from(exported.bytes)); return Promise.resolve()
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); .then(() => {
if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => { if (!area) throw new Error('请先导入 OSM 文件。');
if (!area) throw new Error("请先导入 OSM 文件。"); const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
const document = validateDocument(body, fs.readFileSync(area.input, "utf8")); response.writeHead(200, {
writeJsonAtomic(area.outputs.nativeTrafficSignals, document); 'Content-Type': 'application/zip',
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) }); 'Content-Disposition': `attachment; filename="${area.id}.native-road.zip"`,
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); 'Content-Length': exported.bytes.length,
if (request.method === "POST" && url.pathname === "/api/traffic-signals/generate") return Promise.resolve().then(() => { 'Cache-Control': 'no-store',
if (!area) throw new Error("请先导入 OSM 文件。"); });
const compiled = readCompiled(area); response.end(Buffer.from(exported.bytes));
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")); .catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid)); if (request.method === 'POST' && url.pathname === '/api/traffic-signals')
current.assemblies.features.push(...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid))); return readBody(request)
writeJsonAtomic(area.outputs.nativeTrafficSignals, current); .then((body) => {
sendJson(response, 200, { ok: true, trafficSignals: current, runtime: runtime(current), generated: generated.assemblies.features.length, compiled: Boolean(compiled) }); if (!area) throw new Error('请先导入 OSM 文件。');
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); const document = validateDocument(body, fs.readFileSync(area.input, 'utf8'));
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => { writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
if (!area) throw new Error("请先导入 OSM 文件。"); sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
const compiled = readCompiled(area); })
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints }); .catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides); if (request.method === 'POST' && url.pathname === '/api/traffic-signals/generate')
sendJson(response, 200, { ok: true, overrides }); return Promise.resolve()
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); .then(() => {
if (request.method === "POST" && url.pathname === "/api/junction-clusters") return readBody(request).then((body) => { if (!area) throw new Error('请先导入 OSM 文件。');
if (!area) throw new Error("请先导入 OSM 文件。"); const compiled = readCompiled(area);
if (!debug) throw new Error("该接口仅在 --debug 模式下可用。"); const generated = generate(
const added = addJunctionCluster(context.configPath, body, readCompiled(area), context.readAreaConfig, context.repoRoot); fs.readFileSync(area.input, 'utf8'),
context.compileFresh(); readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'vehicle_stop_lines.geojson')),
const refreshed = context.readAreaConfig(context.configPath, { repoRoot: context.repoRoot }); readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'intersection_surface.geojson')),
sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) }); );
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); const current = validateDocument(
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => { readJson(area.outputs.nativeTrafficSignals),
if (!area || typeof context.compileFresh !== "function") throw new Error("请先导入 OSM 文件。"); fs.readFileSync(area.input, 'utf8'),
context.compileFresh(); );
sendJson(response, 200, state(session.area, junctionReference, debug)); const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message })); current.assemblies.features.push(
sendJson(response, 404, { error: "Not found" }); ...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) => {
if (!area) throw new Error('请先导入 OSM 文件。');
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 (!area) throw new Error('请先导入 OSM 文件。');
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(() => {
if (!area || typeof context.compileFresh !== 'function') throw new Error('请先导入 OSM 文件。');
context.compileFresh();
sendJson(response, 200, state(session.area, junctionReference, debug));
})
.catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
if (request.method === 'GET' && !url.pathname.startsWith('/api/')) return sendWorkbenchAsset(response, url.pathname);
sendJson(response, 404, { error: 'Not found' });
} }
function state(area, junctionReference = null, debug = false) { function state(area, junctionReference = null, debug = false) {
const nativeDir = area.outputs.nativeRoadDir; const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, "road_surface.geojson") : null; const osm2streetsRoadSurface = area.outputs.geojsonDir
? path.join(area.outputs.geojsonDir, 'road_surface.geojson')
: null;
const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals) const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals)
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8")) ? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, 'utf8'))
: { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } }; : {
schema: 'native-traffic-signals/v1',
provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] },
};
const trafficRuntime = runtime(trafficSignals); const trafficRuntime = runtime(trafficSignals);
const compiled = readCompiled(area); 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: osm2streetsRoadSurface && fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } }; 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:
osm2streetsRoadSurface && fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null,
},
};
} }
// The compiler reports candidates as advisory diagnostics. Lift them into their // 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 // own payload with a stable index so the map can label them "#1, #2, ..." and
@@ -113,16 +217,16 @@ function state(area, junctionReference = null, debug = false) {
// later command, and the file is git-tracked so a bad accept stays revertible. // later command, and the file is git-tracked so a bad accept stays revertible.
function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot) { function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot) {
const index = Number(body?.index); const index = Number(body?.index);
if (!Number.isInteger(index)) throw new Error("请求缺少候选编号 index。"); if (!Number.isInteger(index)) throw new Error('请求缺少候选编号 index。');
const candidate = junctionCandidates(compiled).find((item) => item.index === index); const candidate = junctionCandidates(compiled).find((item) => item.index === index);
if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`); if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`);
const raw = readJson(configPath); const raw = readJson(configPath);
const templates = raw.nativeRoad?.junctionTemplates; const templates = raw.nativeRoad?.junctionTemplates;
if (!templates) throw new Error("区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。"); if (!templates) throw new Error('区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。');
const clusters = Array.isArray(templates.clusters) ? templates.clusters : []; const clusters = Array.isArray(templates.clusters) ? templates.clusters : [];
const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String))); const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String)));
const clash = candidate.nodeIds.filter((nodeId) => taken.has(String(nodeId))); const clash = candidate.nodeIds.filter((nodeId) => taken.has(String(nodeId)));
if (clash.length) throw new Error(`节点 ${clash.join("、")} 已属于其他复杂路口配置。`); if (clash.length) throw new Error(`节点 ${clash.join('、')} 已属于其他复杂路口配置。`);
const id = uniqueClusterId(`cluster-${candidate.nodeIds[0]}`, new Set(clusters.map((cluster) => cluster.id))); const id = uniqueClusterId(`cluster-${candidate.nodeIds[0]}`, new Set(clusters.map((cluster) => cluster.id)));
const cluster = { const cluster = {
id, id,
@@ -132,7 +236,13 @@ function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot
outerRadiusExtraMeters: 18, outerRadiusExtraMeters: 18,
nodeIds: candidate.nodeIds.map(String), nodeIds: candidate.nodeIds.map(String),
}; };
const next = { ...raw, nativeRoad: { ...raw.nativeRoad, junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] } } }; const next = {
...raw,
nativeRoad: {
...raw.nativeRoad,
junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] },
},
};
const staging = `${configPath}.candidate-${process.pid}.json`; const staging = `${configPath}.candidate-${process.pid}.json`;
fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`); fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`);
try { try {
@@ -148,55 +258,176 @@ function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot
function uniqueClusterId(base, taken) { function uniqueClusterId(base, taken) {
if (!taken.has(base)) return base; if (!taken.has(base)) return base;
for (let suffix = 2; suffix < 100; suffix += 1) if (!taken.has(`${base}-${suffix}`)) return `${base}-${suffix}`; for (let suffix = 2; suffix < 100; suffix += 1) if (!taken.has(`${base}-${suffix}`)) return `${base}-${suffix}`;
throw new Error("无法生成唯一的 cluster id。"); throw new Error('无法生成唯一的 cluster id。');
} }
function junctionCandidates(compiled) { function junctionCandidates(compiled) {
return (compiled?.diagnostics || []) return (compiled?.diagnostics || [])
.filter((item) => item.rule === "complex-junction-candidate" && item.suggestedCluster) .filter((item) => item.rule === 'complex-junction-candidate' && item.suggestedCluster)
.sort((first, second) => second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount || first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters) .sort(
.map((item, index) => ({ index: index + 1, id: item.id, message: item.message, coordinate: item.geometry?.coordinates || null, ...item.suggestedCluster })); (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) { function readJunctionReference(file) {
if (!fs.existsSync(file)) throw new Error(`Junction reference not found: ${file}`); if (!fs.existsSync(file)) throw new Error(`Junction reference not found: ${file}`);
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, "utf8"))); const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, 'utf8')));
return { source: file, coordinateSystem: "GCJ-02", converted }; return { source: file, coordinateSystem: 'GCJ-02', converted };
} }
function readUpload(request, session) { function readUpload(request, session) {
return readMultipart(request, 20 * 1024 * 1024).then(({ filename, data }) => { return readMultipart(request, 20 * 1024 * 1024).then(({ filename, data }) => {
if (!filename || !/\.osm$/i.test(filename)) throw new Error("请选择 .osm 文件。"); if (!filename || !/\.osm$/i.test(filename)) throw new Error('请选择 .osm 文件。');
if (!data.length) throw new Error("OSM 文件不能为空。"); if (!data.length) throw new Error('OSM 文件不能为空。');
const base = path.basename(filename, path.extname(filename)).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "osm-import"; const base =
path
.basename(filename, path.extname(filename))
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase() || 'osm-import';
fs.mkdirSync(session.dataRoot, { recursive: true }); fs.mkdirSync(session.dataRoot, { recursive: true });
const root = fs.mkdtempSync(path.join(session.dataRoot, "import-")); const root = fs.mkdtempSync(path.join(session.dataRoot, 'import-'));
const areaId = `${base}-${path.basename(root).slice(-6)}`; const areaId = `${base}-${path.basename(root).slice(-6)}`;
const outRoot = path.join(root, "outputs"); const outRoot = path.join(root, 'outputs');
const input = { areaId, osmFile: path.join(root, "source.osm"), outDir: path.join(outRoot, "native-road"), stagingDir: path.join(outRoot, "_pipeline"), overridesFile: path.join(root, "native-road-overrides.json"), trafficSignalsFile: path.join(root, "native-traffic-signals.json"), options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } } }; const input = {
areaId,
osmFile: path.join(root, 'source.osm'),
outDir: path.join(outRoot, 'native-road'),
stagingDir: path.join(outRoot, '_pipeline'),
overridesFile: path.join(root, 'native-road-overrides.json'),
trafficSignalsFile: path.join(root, 'native-traffic-signals.json'),
options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } },
};
fs.writeFileSync(input.osmFile, data); fs.writeFileSync(input.osmFile, data);
fs.writeFileSync(input.overridesFile, JSON.stringify({ schema: "native-road-overrides/v1", overrides: [] }, null, 2)); fs.writeFileSync(
fs.writeFileSync(input.trafficSignalsFile, JSON.stringify({ schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } }, null, 2)); input.overridesFile,
try { const compiled = compileInput(input); session.area = compiled.area; return compiled; } catch (error) { fs.rmSync(root, { recursive: true, force: true }); throw error; } JSON.stringify({ schema: 'native-road-overrides/v1', overrides: [] }, null, 2),
);
fs.writeFileSync(
input.trafficSignalsFile,
JSON.stringify(
{
schema: 'native-traffic-signals/v1',
provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] },
},
null,
2,
),
);
try {
const compiled = compileInput(input);
session.area = compiled.area;
return compiled;
} catch (error) {
fs.rmSync(root, { recursive: true, force: true });
throw error;
}
}); });
} }
function readMultipart(request, limit) { return new Promise((resolve, reject) => { function readMultipart(request, limit) {
const type = request.headers["content-type"] || ""; const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type); if (!match) return reject(new Error("请使用 multipart/form-data 上传 OSM 文件。")); return new Promise((resolve, reject) => {
const boundary = `--${match[1] || match[2]}`; const chunks = []; let size = 0; const type = request.headers['content-type'] || '';
request.on("data", (chunk) => { size += chunk.length; if (size > limit) { reject(new Error("上传文件超过 20 MB 限制。")); request.destroy(); return; } chunks.push(chunk); }); const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type);
request.on("error", reject); request.on("end", () => { const body = Buffer.concat(chunks); const start = body.indexOf(Buffer.from("\r\n\r\n")); const end = body.lastIndexOf(Buffer.from(`\r\n${boundary}--`)); if (start < 0 || end < start) return reject(new Error("上传内容格式无效。")); const header = body.slice(0, start).toString(); const name = /filename="([^"]*)"/i.exec(header)?.[1] || "upload.osm"; resolve({ filename: name, data: body.slice(start + 4, end) }); }); if (!match) return reject(new Error('请使用 multipart/form-data 上传 OSM 文件。'));
}); } const boundary = `--${match[1] || match[2]}`;
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); } const chunks = [];
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } let size = 0;
function readLayer(file) { return fs.existsSync(file) ? readJson(file) : { type: "FeatureCollection", features: [] }; } request.on('data', (chunk) => {
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); }); } size += chunk.length;
function sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); } if (size > limit) {
reject(new Error('上传文件超过 20 MB 限制。'));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('error', reject);
request.on('end', () => {
const body = Buffer.concat(chunks);
const start = body.indexOf(Buffer.from('\r\n\r\n'));
const end = body.lastIndexOf(Buffer.from(`\r\n${boundary}--`));
if (start < 0 || end < start) return reject(new Error('上传内容格式无效。'));
const header = body.slice(0, start).toString();
const name = /filename="([^"]*)"/i.exec(header)?.[1] || 'upload.osm';
resolve({ filename: name, data: body.slice(start + 4, end) });
});
});
}
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 sendWorkbenchApp(response) {
const built = path.join(__dirname, 'client', 'dist', 'index.html');
return sendFile(
response,
fs.existsSync(built) ? built : path.join(__dirname, 'client', 'index.html'),
'text/html; charset=utf-8',
);
}
function sendWorkbenchAsset(response, pathname) {
const root = path.join(__dirname, 'client', 'dist');
const file = path.resolve(root, `.${pathname}`);
if (file.startsWith(`${root}${path.sep}`) && fs.existsSync(file) && fs.statSync(file).isFile()) {
const extension = path.extname(file);
const types = {
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.woff2': 'font/woff2',
};
return sendFile(response, file, types[extension] || 'application/octet-stream');
}
return sendWorkbenchApp(response);
}
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) { function sendVendorFile(response, pathname, repoRoot) {
const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname); const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname);
if (!match) return sendJson(response, 404, { error: "Not found" }); if (!match) return sendJson(response, 404, { error: 'Not found' });
const root = path.join(repoRoot, "node_modules", match[1]); const root = path.join(repoRoot, 'node_modules', match[1]);
const file = path.resolve(root, match[2]); 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" }); if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile())
return sendFile(response, file, file.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8"); 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`);
} }
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, readMultipart }; module.exports = { startWorkbench, readMultipart };