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"
},
"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":"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}
{
"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": "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
* 25px x 25px
* SVG
* Single color fill
* Path outline only, no stroke
* Pixel grid aligned (where possible)
- 25px x 25px
- SVG
- Single color fill
- Path outline only, no stroke
- Pixel grid aligned (where possible)
### Arrows
@@ -25,7 +25,6 @@ lane arrows.
<img alt='Standard Arrow' width='300px' src='docs/standard_arrow.png'/>
Arrowhead rotation angles are chosen based on the turn lane indication:
<table>
@@ -84,13 +83,12 @@ As the number of arrows increases, the arrows scale down:
</tr>
</table>
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

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
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
i. the right to reproduce, adapt, distribute, perform, display, communicate,
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
depicted in a Work;
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
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
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
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
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
**2. Waiver.** To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
@@ -94,25 +94,25 @@ Affirmer's express Statement of Purpose.
**4. Limitations and Disclaimers.**
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
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
discoverable, all to the greatest extent permissible under applicable law.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
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
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
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
disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work.
c. Affirmer disclaims responsibility for clearing rights of other persons
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
disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work.
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
CC0 or use of the Work.
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
CC0 or use of the Work.
For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/>

View File

@@ -1,20 +1,22 @@
#!/usr/bin/env node
"use strict";
'use strict';
const fs = require("fs");
const path = require("path");
const { compiler } = require("../src");
const { exportNativeRoadPackage } = require("../src/export/native-road-package");
const fs = require('fs');
const path = require('path');
const { compiler } = require('../src');
const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const args = process.argv.slice(2);
const index = args.indexOf("--input");
const exportIndex = args.indexOf("--export-zip");
const index = args.indexOf('--input');
const exportIndex = args.indexOf('--export-zip');
const expectedLength = exportIndex >= 0 ? 4 : 2;
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 input = JSON.parse(fs.readFileSync(inputFile, "utf8"));
const input = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
const { result, comparison } = compiler.compileInput(input);
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
"use strict";
'use strict';
const path = require("path");
const { startWorkbench } = require("../workbench/server");
const fs = require("fs");
const path = require('path');
const { startWorkbench } = require('../workbench/server');
const fs = require('fs');
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 input = inputFile ? JSON.parse(fs.readFileSync(inputFile, "utf8")) : null;
const portIndex = args.indexOf("--port");
const input = inputFile ? JSON.parse(fs.readFileSync(inputFile, 'utf8')) : null;
const portIndex = args.indexOf('--port');
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": {
"test": "node test/index.js && node test/fixtures.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": {
"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 path = require("path");
const fs = require('fs');
const path = require('path');
function checkOutput({ areaId, outDir }) {
if (typeof areaId !== "string" || !areaId) throw new Error("RoadCompilerCheckInput.areaId must be a non-empty string");
if (typeof outDir !== "string" || !outDir) throw new Error("RoadCompilerCheckInput.outDir must be a non-empty string");
const compiledPath = path.join(outDir, "compiled.json");
if (typeof areaId !== 'string' || !areaId)
throw new Error('RoadCompilerCheckInput.areaId must be a non-empty string');
if (typeof outDir !== 'string' || !outDir)
throw new Error('RoadCompilerCheckInput.outDir must be a non-empty string');
const compiledPath = path.join(outDir, 'compiled.json');
if (!fs.existsSync(compiledPath)) throw new Error(`Native road output is missing: ${compiledPath}`);
const compiled = readJson(compiledPath);
const connectors = readJson(path.join(outDir, "layers", "connectors.geojson"));
const connectors = readJson(path.join(outDir, 'layers', 'connectors.geojson'));
const published = new Set(connectors.features.map((feature) => feature.properties.movement_id));
const failures = [];
for (const movement of compiled.movements || []) {
if (movement.geometryPublished && !published.has(movement.id)) failures.push(`Published movement has no connector: ${movement.id}`);
if (!movement.geometryPublished && published.has(movement.id)) failures.push(`Non-published movement has a connector: ${movement.id}`);
if (movement.geometryPublished && !published.has(movement.id))
failures.push(`Published movement has no connector: ${movement.id}`);
if (!movement.geometryPublished && published.has(movement.id))
failures.push(`Non-published movement has a connector: ${movement.id}`);
if (!movement.geometryStatus) failures.push(`Movement has no geometry status: ${movement.id}`);
}
const errors = (compiled.diagnostics || []).filter((item) => item.severity === "error");
const warnings = (compiled.diagnostics || []).filter((item) => item.severity === "warning");
return { schema: "native-road-check/v1", areaId, ok: failures.length === 0 && errors.length === 0, movementCount: (compiled.movements || []).length, connectorCount: connectors.features.length, errors: errors.map((item) => ({ id: item.id, rule: item.rule, message: item.message })), warningCount: warnings.length, failures };
const errors = (compiled.diagnostics || []).filter((item) => item.severity === 'error');
const warnings = (compiled.diagnostics || []).filter((item) => item.severity === 'warning');
return {
schema: 'native-road-check/v1',
areaId,
ok: failures.length === 0 && errors.length === 0,
movementCount: (compiled.movements || []).length,
connectorCount: connectors.features.length,
errors: errors.map((item) => ({ id: item.id, rule: item.rule, message: item.message })),
warningCount: warnings.length,
failures,
};
}
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
module.exports = { checkOutput };

View File

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

View File

@@ -1,7 +1,7 @@
"use strict";
'use strict';
const fs = require("fs");
const { convertGeoJson, boundsOf } = require("../reference/gaode");
const fs = require('fs');
const { convertGeoJson, boundsOf } = require('../reference/gaode');
const metricsCache = new WeakMap();
const CORNER_FILLET_SEGMENTS = 12;
// Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band
@@ -15,8 +15,24 @@ const SIDEWALK_CORNER_OVERRUN_METERS = 6;
function buildComplexJunctionGeometry(model, cluster, helpers) {
const nodeIds = new Set(cluster.nodeIds.map(String));
const nodes = cluster.nodeIds.map((id) => helpers.junctionPlans.get(String(id))?.node).filter(Boolean);
if (nodes.length < 2) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-nodes", "复合路口至少需要两个有效节点。", null)] };
const center = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]);
if (nodes.length < 2)
return {
features: [],
diagnostics: [
helpers.diagnostic(
'warning',
`junction-cluster:${cluster.id}`,
[...nodeIds],
'complex-junction-insufficient-nodes',
'复合路口至少需要两个有效节点。',
null,
),
],
};
const center = nodes.reduce(
(sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length],
[0, 0],
);
const approaches = [];
const carriageways = [];
for (const [nodeId, plan] of helpers.junctionPlans) {
@@ -31,39 +47,92 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
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 sorted = [...approaches].sort((a, b) => a.heading - b.heading);
const arms = sorted.map((representative) => ({
representative,
heading: averageHeading(carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20).map((candidate) => candidate.heading)),
members: carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20),
heading: averageHeading(
carriageways
.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20)
.map((candidate) => candidate.heading),
),
members: carriageways.filter(
(candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20,
),
}));
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
const boundaryParts = [];
const crosswalks = [];
const stopLines = [];
const islands = [];
const armCrosswalkRadius = coreRadius * .68;
const armCrosswalkRadius = coreRadius * 0.68;
for (const item of carriageways) {
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 innerHalf = outerHalf;
boundaryParts.push({ item, outer, inner, outerHalf, innerHalf });
const incomingRoad = item.approach.roadIds.map((roadId) => model.roads.find((road) => road.id === roadId)).find((road) => String(road?.sourceNodeIds.at(-1)) === String(item.nodeId));
const incomingRoad = item.approach.roadIds
.map((roadId) => model.roads.find((road) => road.id === roadId))
.find((road) => String(road?.sourceNodeIds.at(-1)) === String(item.nodeId));
if (incomingRoad) {
// Keep the stop bar just outside the road crosswalk. The previous fixed
// core-radius offset placed it nearly ten metres beyond the crossing.
const stopCenter = pointOnCarriagewayRadius(item, center, armCrosswalkRadius + 3, helpers);
const ring = [
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, -.24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, .24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, .24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
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,
),
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
@@ -89,15 +158,15 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
const arm = arms[index];
if (!arm.crosswalkFrame) continue;
const item = arm.representative;
const roadEdgeInset = .35;
const usableSpan = Math.max(.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2);
const roadEdgeInset = 0.35;
const usableSpan = Math.max(0.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2);
const endpoints = [
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, -usableSpan / 2),
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, usableSpan / 2),
];
const groupDepth = arm.crosswalkFrame.groupDepth;
const stripeWidth = .42;
const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / .82) + 1);
const stripeWidth = 0.42;
const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / 0.82) + 1);
const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0;
arm.crosswalkFrame.center = midpoint(...endpoints);
arm.crosswalkFrame.endpoints = endpoints;
@@ -107,13 +176,54 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
const along = stripeWidth / 2 + stripe * stripeSpacing;
const centerPoint = helpers.offsetCoordinate(endpoints[0], bearing(...endpoints), along);
const ring = [
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
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
@@ -128,36 +238,65 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
const bisector = normalizeHeading(first.heading + delta / 2);
const frameCorner = frameCorners[index];
if (!frameCorner) continue;
const cornerStripeSpacing = .62;
const cornerStripeWidth = .4;
const cornerStripeSpacing = 0.62;
const cornerStripeWidth = 0.4;
const cornerGroupHalfDepth = (5 * cornerStripeSpacing + cornerStripeWidth) / 2;
const endpointForCorner = (arm) => [...arm.crosswalkFrame.endpoints].sort((a, b) => helpers.distanceMeters(a, frameCorner) - helpers.distanceMeters(b, frameCorner))[0];
const endpointForCorner = (arm) =>
[...arm.crosswalkFrame.endpoints].sort(
(a, b) => helpers.distanceMeters(a, frameCorner) - helpers.distanceMeters(b, frameCorner),
)[0];
const outerEdgeAtCorner = (arm) => {
const endpoint = endpointForCorner(arm);
return [arm.heading, arm.heading + 180]
.map((heading) => helpers.offsetCoordinate(endpoint, heading, arm.crosswalkFrame.groupDepth / 2))
.sort((a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector))[0];
.sort(
(a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector),
)[0];
};
const islandBaseGap = .05;
const islandBaseGap = 0.05;
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 islandCrossingClearance = .2;
const islandCrossingClearance = 0.2;
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 cornerCrossingCenter = helpers.offsetCoordinate(frameCorner, bisector, cornerCrossingOffset);
const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], .24);
islands.push({ type: "Feature", properties: { native_id: `complex-corner-island:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-island", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, frame_corner: frameCorner, base_points: islandBase, apex_point: islandApex, inner_radius_m: islandInnerRadius, outer_radius_m: islandOuterRadius, base_width_m: helpers.distanceMeters(...islandBase), crossing_clearance_m: islandCrossingClearance, corner_rounding_ratio: .24, provenance: "native-road-complex-junction-corner/v7-road-gap-fill" }, geometry: { type: "Polygon", coordinates: [islandRing] } });
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: 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) {
const stripeOffset = (stripe - 2.5) * cornerStripeSpacing;
const stripeCenter = helpers.offsetCoordinate(cornerCrossingCenter, bisector, stripeOffset);
const stripeRadius = directionalProjectionMeters(center, stripeCenter, bisector);
const curbPair = limitedCornerPair(first, second, stripeRadius, bisector, center, 6.5, helpers);
if (!curbPair) continue;
const halfSpan = Math.max(.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2);
const halfSpan = Math.max(0.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2);
cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan);
const stripePair = [
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),
];
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
@@ -195,17 +349,46 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
// outside that band the two edges are near parallel and any fillet fitted
// to them would sweep across the carriageways instead of the corner.
if (apexReach === null || apexReach < 1 || apexReach > outerRadius) {
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-corner-fillet-fallback", "该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。", center));
cornerDiagnostics.push(
helpers.diagnostic(
'warning',
`junction-cluster:${cluster.id}`,
[...nodeIds],
'complex-junction-corner-fillet-fallback',
'该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。',
center,
),
);
continue;
}
// Tangent distance for a circle of `cornerRadius` inscribed in a wedge of
// opening `delta`, clamped so the tangent points stay on the built arms.
const tangentDistance = Math.min(cornerRadius / Math.tan(delta * Math.PI / 360), Math.max(2, outerRadius - apexReach));
const tangentDistance = Math.min(
cornerRadius / Math.tan((delta * Math.PI) / 360),
Math.max(2, outerRadius - apexReach),
);
const tangents = edges.map((edge) => helpers.offsetCoordinate(apex, edge.heading, tangentDistance));
const curve = quadraticCurve(tangents[0], apex, tangents[1], CORNER_FILLET_SEGMENTS);
const ring = [...curve, center, curve[0]];
if (!ring.every((point) => point.every(Number.isFinite))) continue;
cornerFills.push({ type: "Feature", properties: { native_id: `complex-corner-fillet:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-fillet", complex_part: "corner-fillet", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, corner_radius_m: cornerRadius, tangent_distance_m: Math.round(tangentDistance * 100) / 100, apex_reach_m: Math.round(apexReach * 100) / 100, provenance: "native-road-complex-junction-corner-fillet/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
cornerFills.push({
type: 'Feature',
properties: {
native_id: `complex-corner-fillet:${cluster.id}:${index + 1}`,
cluster_id: cluster.id,
kind: 'complex-corner-fillet',
complex_part: 'corner-fillet',
corner_index: index + 1,
from_heading: first.heading,
to_heading: second.heading,
bisector_heading: bisector,
corner_radius_m: cornerRadius,
tangent_distance_m: Math.round(tangentDistance * 100) / 100,
apex_reach_m: Math.round(apexReach * 100) / 100,
provenance: 'native-road-complex-junction-corner-fillet/v1',
},
geometry: { type: 'Polygon', coordinates: [ring] },
});
// The straight pedestrian strips are trimmed at the cluster boundary, so
// two arms that both carry a footway still meet as two loose ends across
@@ -214,24 +397,108 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
// `second`, so each arm must carry the footway on that facing side.
if (!armCarriesSidewalk(first, model, true) || !armCarriesSidewalk(second, model, false)) continue;
const curb = [
...edgeRunToRadius(apex, edges[0], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers).reverse(),
...edgeRunToRadius(
apex,
edges[0],
tangentDistance,
outerRadius + SIDEWALK_CORNER_OVERRUN_METERS,
center,
helpers,
).reverse(),
...curve.slice(1, -1),
...edgeRunToRadius(apex, edges[1], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers),
...edgeRunToRadius(
apex,
edges[1],
tangentDistance,
outerRadius + SIDEWALK_CORNER_OVERRUN_METERS,
center,
helpers,
),
];
const outerEdge = offsetPolylineAwayFromCenter(curb, center, SIDEWALK_WIDTH_METERS, helpers);
const sidewalkRing = [...curb, ...outerEdge.slice().reverse(), curb[0]];
if (!sidewalkRing.every((point) => point.every(Number.isFinite)) || ringSelfIntersects(sidewalkRing)) {
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-sidewalk-corner-fallback", "该夹角的人行道转角几何自交或无效,已跳过,两侧步行带保持断开。", center));
cornerDiagnostics.push(
helpers.diagnostic(
'warning',
`junction-cluster:${cluster.id}`,
[...nodeIds],
'complex-junction-sidewalk-corner-fallback',
'该夹角的人行道转角几何自交或无效,已跳过,两侧步行带保持断开。',
center,
),
);
continue;
}
sidewalkCorners.push({ type: "Feature", properties: { native_id: `complex-sidewalk-corner:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-sidewalk-corner", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, width_m: SIDEWALK_WIDTH_METERS, overrun_m: SIDEWALK_CORNER_OVERRUN_METERS, provenance: "native-road-complex-junction-sidewalk-corner/v1" }, geometry: { type: "Polygon", coordinates: [sidewalkRing] } });
sidewalkCorners.push({
type: 'Feature',
properties: {
native_id: `complex-sidewalk-corner:${cluster.id}:${index + 1}`,
cluster_id: cluster.id,
kind: 'complex-sidewalk-corner',
corner_index: index + 1,
from_heading: first.heading,
to_heading: second.heading,
bisector_heading: bisector,
width_m: SIDEWALK_WIDTH_METERS,
overrun_m: SIDEWALK_CORNER_OVERRUN_METERS,
provenance: 'native-road-complex-junction-sidewalk-corner/v1',
},
geometry: { type: 'Polygon', coordinates: [sidewalkRing] },
});
}
const corePoints = boundaryParts.flatMap(({ item, inner, innerHalf }) => [helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf)]).sort((first, second) => angleAround(center, first) - angleAround(center, second));
const coreRing = roundedPolygonRing(corePoints, .16);
const features = [{ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:core`, cluster_id: cluster.id, kind: "complex-core", complex_part: "core", center, radius_m: coreRadius, configured_radius_m: cluster.coreRadiusMeters, approach_count: approaches.length, carriageway_count: carriageways.length, approach_headings: sorted.map((item) => Math.round(item.heading * 10) / 10), corner_rounding_ratio: .16, provenance: "native-road-complex-junction/v6-rounded-core" }, geometry: { type: "Polygon", coordinates: [coreRing] } }];
const corePoints = boundaryParts
.flatMap(({ item, inner, innerHalf }) => [
helpers.offsetCoordinate(inner, item.heading + 90, innerHalf),
helpers.offsetCoordinate(inner, item.heading - 90, innerHalf),
])
.sort((first, second) => angleAround(center, first) - angleAround(center, second));
const coreRing = roundedPolygonRing(corePoints, 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) {
const ring = [helpers.offsetCoordinate(outer, item.heading + 90, outerHalf), helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf), helpers.offsetCoordinate(outer, item.heading - 90, outerHalf), helpers.offsetCoordinate(outer, item.heading + 90, outerHalf)];
features.push({ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:carriageway:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-approach", complex_part: "carriageway", heading_deg: item.heading, lane_count: item.approach.roadIds.reduce((sum, roadId) => sum + (model.roads.find((road) => road.id === roadId)?.laneCount || 0), 0), width_m: item.approach.widthMeters, provenance: "native-road-complex-junction/v5" }, geometry: { type: "Polygon", coordinates: [ring] } });
const ring = [
helpers.offsetCoordinate(outer, item.heading + 90, outerHalf),
helpers.offsetCoordinate(inner, item.heading + 90, innerHalf),
helpers.offsetCoordinate(inner, item.heading - 90, innerHalf),
helpers.offsetCoordinate(outer, item.heading - 90, outerHalf),
helpers.offsetCoordinate(outer, item.heading + 90, outerHalf),
];
features.push({
type: 'Feature',
properties: {
native_id: `complex-junction:${cluster.id}:carriageway:${item.approach.segmentId}`,
cluster_id: cluster.id,
kind: 'complex-approach',
complex_part: 'carriageway',
heading_deg: item.heading,
lane_count: item.approach.roadIds.reduce(
(sum, roadId) => sum + (model.roads.find((road) => road.id === roadId)?.laneCount || 0),
0,
),
width_m: item.approach.widthMeters,
provenance: 'native-road-complex-junction/v5',
},
geometry: { type: 'Polygon', coordinates: [ring] },
});
}
// Corner fills come last so they overlay the rectangular carriageway ends
// they are smoothing; they never replace an OSM-derived road surface.
@@ -239,20 +506,56 @@ function buildComplexJunctionGeometry(model, cluster, helpers) {
// `coreRadiusMeters` is only consulted when there is no reference geometry.
// Under calibration the radius comes from the reference span, so a configured
// value that silently does nothing has to be reported, not swallowed.
const configuredRadiusIgnored = calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > .5;
const configuredRadiusIgnored =
calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > 0.5;
const configurationDiagnostics = configuredRadiusIgnored
? [helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-configured-radius-ignored", `已按参考几何校准核心半径为 ${Math.round(coreRadius * 10) / 10} 米,配置的 coreRadiusMeters=${cluster.coreRadiusMeters} 在有参考文件时不生效。`, center)]
? [
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) {
if (!cluster.referenceFile || !fs.existsSync(cluster.referenceFile)) return null;
try {
const converted = convertGeoJson(JSON.parse(fs.readFileSync(cluster.referenceFile, "utf8")));
const bounds = boundsOf({ features: converted.features.filter((feature) => [1, 2, 3, 4].includes(Number(feature.properties?.type))) });
const lonScale = 111320 * Math.cos(((bounds.minLat + bounds.maxLat) / 2) * Math.PI / 180);
return { longSpanMeters: (bounds.maxLon - bounds.minLon) * lonScale, shortSpanMeters: (bounds.maxLat - bounds.minLat) * 111320 };
const converted = convertGeoJson(JSON.parse(fs.readFileSync(cluster.referenceFile, 'utf8')));
const bounds = boundsOf({
features: converted.features.filter((feature) => [1, 2, 3, 4].includes(Number(feature.properties?.type))),
});
const lonScale = 111320 * Math.cos((((bounds.minLat + bounds.maxLat) / 2) * Math.PI) / 180);
return {
longSpanMeters: (bounds.maxLon - bounds.minLon) * lonScale,
shortSpanMeters: (bounds.maxLat - bounds.minLat) * 111320,
};
} catch (_) {
return null;
}
@@ -268,29 +571,37 @@ function complexJunctionMetrics(cluster) {
// — a 24 m dual-carriageway arm was being fitted into a 16.6 m core no matter
// what the config asked for. The calibrated branch is unchanged.
const coreRadius = calibration
? Math.max(12, Math.min(24, calibration.shortSpanMeters * .14))
? Math.max(12, Math.min(24, calibration.shortSpanMeters * 0.14))
: 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);
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
// it sits at heading+90 and the one counter-clockwise at heading-90. A road's
// own sidewalk flags are relative to its digitisation direction, so flip them
// whenever the arm runs against that direction.
function armCarriesSidewalk(arm, model, cornerIsClockwise) {
return arm.members.some((member) => member.approach.roadIds
.map((roadId) => model.roads.find((road) => road.id === roadId))
.filter(Boolean)
.some((road) => {
const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId);
const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft;
const onCounterClockwiseSide = outwardIsForward ? road.sidewalkLeft : road.sidewalkRight;
return Boolean(cornerIsClockwise ? onClockwiseSide : onCounterClockwiseSide);
}));
return arm.members.some((member) =>
member.approach.roadIds
.map((roadId) => model.roads.find((road) => road.id === roadId))
.filter(Boolean)
.some((road) => {
const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId);
const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft;
const onCounterClockwiseSide = outwardIsForward ? road.sidewalkLeft : road.sidewalkRight;
return Boolean(cornerIsClockwise ? onClockwiseSide : onCounterClockwiseSide);
}),
);
}
// Walk outward along a wedge edge from its tangent point until the curb reaches
@@ -322,8 +633,10 @@ function offsetPolylineAwayFromCenter(points, center, meters, helpers) {
function ringSelfIntersects(ring) {
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
const straddles = (p1, p2, p3, p4) => {
const d1 = cross(p3, p4, p1); const d2 = cross(p3, p4, p2);
const d3 = cross(p1, p2, p3); const d4 = cross(p1, p2, p4);
const d1 = cross(p3, p4, p1);
const d2 = cross(p3, p4, p2);
const d3 = cross(p1, p2, p3);
const d4 = cross(p1, p2, p4);
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
};
for (let first = 0; first < ring.length - 1; first += 1) {
@@ -335,27 +648,36 @@ function ringSelfIntersects(ring) {
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) {
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 radians = (heading + 90) * Math.PI / 180;
const radians = ((heading + 90) * Math.PI) / 180;
return east * Math.sin(radians) + north * Math.cos(radians);
}
function bearing(first, second) {
const east = (second[0] - first[0]) * Math.cos(first[1] * Math.PI / 180);
const east = (second[0] - first[0]) * Math.cos((first[1] * Math.PI) / 180);
const north = second[1] - first[1];
return Math.atan2(east, north) * 180 / Math.PI;
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) {
const vector = headings.reduce((sum, heading) => {
const radians = heading * Math.PI / 180;
return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)];
}, [0, 0]);
return Math.atan2(vector[0], vector[1]) * 180 / Math.PI;
const vector = headings.reduce(
(sum, heading) => {
const radians = (heading * Math.PI) / 180;
return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)];
},
[0, 0],
);
return (Math.atan2(vector[0], vector[1]) * 180) / Math.PI;
}
function positiveHeadingDelta(first, second) {
return (((second - first) % 360) + 360) % 360;
}
function positiveHeadingDelta(first, second) { return ((second - first) % 360 + 360) % 360; }
function pointOnCarriagewayRadius(item, center, radius, helpers) {
const start = item.approach.line[0];
const startRadius = directionalProjectionMeters(center, start, item.heading);
@@ -374,15 +696,18 @@ function armEnvelopeAtRadius(arm, radius, center, helpers) {
maximum = Math.max(maximum, lateral + halfWidth);
});
if (!Number.isFinite(minimum) || maximum - minimum < 1) return null;
return { center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2), widthMeters: maximum - minimum };
return {
center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2),
widthMeters: maximum - minimum,
};
}
function supportLineIntersection(first, second, origin) {
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
const lonScale = 111320 * Math.cos((origin[1] * Math.PI) / 180);
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
const firstPoint = toLocal(first.center);
const secondPoint = toLocal(second.center);
const direction = (heading) => {
const radians = heading * Math.PI / 180;
const radians = (heading * Math.PI) / 180;
return [Math.sin(radians), Math.cos(radians)];
};
const firstDirection = direction(first.supportHeading);
@@ -391,17 +716,26 @@ function supportLineIntersection(first, second, origin) {
if (Math.abs(denominator) < 1e-6) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
const intersection = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
const intersection = [
firstPoint[0] + firstDirection[0] * distanceAlongFirst,
firstPoint[1] + firstDirection[1] * distanceAlongFirst,
];
return [origin[0] + intersection[0] / lonScale, origin[1] + intersection[1] / 111320];
}
function limitedCornerPair(first, second, radius, bisector, center, maxWidth, helpers) {
const pair = [cornerEdgeAtRadius(first, radius, bisector, center, helpers), cornerEdgeAtRadius(second, radius, bisector, center, helpers)];
const pair = [
cornerEdgeAtRadius(first, radius, bisector, center, helpers),
cornerEdgeAtRadius(second, radius, bisector, center, helpers),
];
if (!pair.every(Boolean)) return null;
const width = helpers.distanceMeters(pair[0], pair[1]);
const middle = midpoint(pair[0], pair[1]);
const halfWidth = Math.max(.4, Math.min(width, maxWidth) / 2);
const acrossHeading = width > .1 ? bearing(pair[0], pair[1]) : bisector + 90;
return [helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth), helpers.offsetCoordinate(middle, acrossHeading, halfWidth)];
const halfWidth = Math.max(0.4, Math.min(width, maxWidth) / 2);
const acrossHeading = width > 0.1 ? bearing(pair[0], pair[1]) : bisector + 90;
return [
helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth),
helpers.offsetCoordinate(middle, acrossHeading, halfWidth),
];
}
function cornerEdgeAtRadius(arm, radius, bisector, center, helpers) {
return cornerEdgeAt(arm, radius, bisector, center, helpers)?.point || null;
@@ -410,15 +744,24 @@ function cornerEdgeAt(arm, radius, bisector, center, helpers) {
const candidates = arm.members.flatMap((member) => {
const point = pointOnCarriagewayRadius(member, center, radius, helpers);
const halfWidth = member.approach.widthMeters / 2;
return [90, -90].map((side) => ({ point: helpers.offsetCoordinate(point, member.heading + side, halfWidth), heading: member.heading }));
return [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) {
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 direction = (heading) => {
const radians = heading * Math.PI / 180;
const radians = (heading * Math.PI) / 180;
return [Math.sin(radians), Math.cos(radians)];
};
const firstPoint = toLocal(first.point);
@@ -429,7 +772,10 @@ function rayIntersection(first, second, origin) {
if (Math.abs(denominator) < 1e-4) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
const local = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
const local = [
firstPoint[0] + firstDirection[0] * distanceAlongFirst,
firstPoint[1] + firstDirection[1] * distanceAlongFirst,
];
if (!local.every(Number.isFinite)) return null;
return [origin[0] + local[0] / lonScale, origin[1] + local[1] / 111320];
}
@@ -438,14 +784,17 @@ function quadraticCurve(start, control, end, segments) {
for (let index = 0; index <= segments; index += 1) {
const t = index / segments;
const u = 1 - t;
result.push([u * u * start[0] + 2 * u * t * control[0] + t * t * end[0], u * u * start[1] + 2 * u * t * control[1] + t * t * end[1]]);
result.push([
u * u * start[0] + 2 * u * t * control[0] + t * t * end[0],
u * u * start[1] + 2 * u * t * control[1] + t * t * end[1],
]);
}
return result;
}
function directionalProjectionMeters(origin, point, heading) {
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
const east = (point[0] - origin[0]) * 111320 * Math.cos((origin[1] * Math.PI) / 180);
const north = (point[1] - origin[1]) * 111320;
const radians = heading * Math.PI / 180;
const radians = (heading * Math.PI) / 180;
return east * Math.sin(radians) + north * Math.cos(radians);
}
function smoothClosedRing(vertices) {
@@ -453,11 +802,18 @@ function smoothClosedRing(vertices) {
// carriageway bends slightly. Sort this local corner only around its own
// centroid before rounding, avoiding a self-crossing safety island while
// keeping the global junction boundary fully OSM-driven.
const centroid = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]);
const ordered = [...vertices].sort((first, second) => Math.atan2(first[1] - centroid[1], first[0] - centroid[0]) - Math.atan2(second[1] - centroid[1], second[0] - centroid[0]));
const centroid = vertices.reduce(
(sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length],
[0, 0],
);
const ordered = [...vertices].sort(
(first, second) =>
Math.atan2(first[1] - centroid[1], first[0] - centroid[0]) -
Math.atan2(second[1] - centroid[1], second[0] - centroid[0]),
);
const points = ordered.flatMap((point, index) => {
const next = ordered[(index + 1) % ordered.length];
return [interpolateCoordinate(point, next, .18), interpolateCoordinate(point, next, .82)];
return [interpolateCoordinate(point, next, 0.18), interpolateCoordinate(point, next, 0.82)];
});
return [...points, points[0]];
}
@@ -469,6 +825,8 @@ function roundedPolygonRing(vertices, ratio) {
});
return [...points, points[0]];
}
function interpolateCoordinate(first, second, ratio) { return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio]; }
function interpolateCoordinate(first, second, ratio) {
return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio];
}
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
// GeoJSON files and the Blender-facing manifest. Keep rendering details limited
// to material slot names; the host owns the actual material definitions.
const LAYER_REGISTRY = Object.freeze([
{ key: "roadSurface", source: "road_surface", role: "surface", materialLayer: "road_surface" },
{ 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: "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" }] } },
{ 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" },
{ key: 'roadSurface', source: 'road_surface', role: 'surface', materialLayer: 'road_surface' },
{ 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: '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' },
],
},
},
{
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) {
return {
contract: "native-road-package/v1.1",
contract: 'native-road-package/v1.1',
areaId,
layers: LAYER_REGISTRY.map(({ source, role, materialLayer, splitBy }) => ({
source,
@@ -34,16 +63,20 @@ function manifestForArea(areaId) {
function validatePublishedLayers(directory, manifest) {
const declared = new Set();
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);
const file = require("path").join(directory, "layers", `${layer.source}.geojson`);
if (!require("fs").existsSync(file)) throw new Error(`Manifest source is missing: ${layer.source}`);
const file = require('path').join(directory, 'layers', `${layer.source}.geojson`);
if (!require('fs').existsSync(file)) throw new Error(`Manifest source is missing: ${layer.source}`);
}
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))
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))
: [];
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 };

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

View File

@@ -1,49 +1,54 @@
"use strict";
'use strict';
const fs = require("fs");
const path = require("path");
const { zipSync } = require("fflate");
const { LAYER_REGISTRY, validatePublishedLayers } = require("../compile/layer-manifest");
const fs = require('fs');
const path = require('path');
const { zipSync } = require('fflate');
const { LAYER_REGISTRY, validatePublishedLayers } = require('../compile/layer-manifest');
const ROOT_FILES = [
"manifest.json",
"compiled.json",
"diagnostics.json",
"comparison.json",
"traffic-signal-assemblies.json",
"traffic-signals.json",
'manifest.json',
'compiled.json',
'diagnostics.json',
'comparison.json',
'traffic-signal-assemblies.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) {
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}`);
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.areaId || typeof manifest.areaId !== "string") throw new Error("Native road package manifest areaId is required.");
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.areaId || typeof manifest.areaId !== 'string')
throw new Error('Native road package manifest areaId is required.');
validatePublishedLayers(directory, manifest);
const files = [];
for (const relative of ROOT_FILES) files.push(relative);
for (const layer of LAYER_REGISTRY) files.push(`layers/${layer.source}.geojson`);
for (const relative of files) {
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() };
}
function exportNativeRoadPackage(directory, destination = null) {
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
// 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;
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['compiled.json'] = Buffer.from(`${JSON.stringify(compiled, 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.
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) {
fs.mkdirSync(path.dirname(destination), { recursive: true });
const temporary = `${destination}.tmp-${process.pid}`;

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
"use strict";
'use strict';
function parseOsm(xml) {
const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/);
const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {};
const candidateBounds = {
minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat),
maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat),
minLon: Number(boundsAttrs.minlon),
minLat: Number(boundsAttrs.minlat),
maxLon: Number(boundsAttrs.maxlon),
maxLat: Number(boundsAttrs.maxlat),
};
const bounds = Object.values(candidateBounds).every(Number.isFinite) ? candidateBounds : null;
const nodes = new Map();
@@ -13,19 +15,19 @@ function parseOsm(xml) {
const nodePattern = /<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g;
for (const match of xml.matchAll(nodePattern)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
if (attrs.action === 'delete' || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
if (!coordinate.every(Number.isFinite)) continue;
nodes.set(attrs.id, coordinate);
const tags = parseTags(match[2] || "");
if (tags.highway === "traffic_signals") {
const tags = parseTags(match[2] || '');
if (tags.highway === 'traffic_signals') {
trafficSignalControls.push({ id: attrs.id, longitude: coordinate[0], latitude: coordinate[1], tags });
}
}
const ways = [];
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete") continue;
if (attrs.action === 'delete') continue;
const body = match[2];
const refs = [];
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
@@ -53,22 +55,36 @@ function parseOsm(xml) {
}
}
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 };
}
function isMotorRoad(tags) {
const highway = tags.highway || "";
return highway && tags.area !== "yes" && !new Set([
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track",
"bridleway", "corridor", "elevator", "platform", "construction",
]).has(highway);
const highway = tags.highway || '';
return (
highway &&
tags.area !== 'yes' &&
!new Set([
'footway',
'path',
'pedestrian',
'steps',
'cycleway',
'service',
'track',
'bridleway',
'corridor',
'elevator',
'platform',
'construction',
]).has(highway)
);
}
function headingBetween(from, to) {
const latitude = (from.latitude + to[1]) / 2 * Math.PI / 180;
return Math.atan2((to[0] - from.longitude) * Math.cos(latitude), to[1] - from.latitude) * 180 / Math.PI;
const latitude = (((from.latitude + to[1]) / 2) * Math.PI) / 180;
return (Math.atan2((to[0] - from.longitude) * Math.cos(latitude), to[1] - from.latitude) * 180) / Math.PI;
}
function dedupeHeadings(arms) {
@@ -94,7 +110,7 @@ function parseTags(body) {
const tags = {};
for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
const tag = xmlAttrs(match[1]);
if (tag.k) tags[tag.k] = tag.v || "";
if (tag.k) tags[tag.k] = tag.v || '';
}
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 EARTH_A = 6378245.0;
@@ -8,17 +8,17 @@ const EARTH_EE = 0.00669342162296594323;
function transformLat(x, y) {
let value = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
value += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3;
value += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3;
value += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
value += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3;
value += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
return value;
}
function transformLon(x, y) {
let value = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
value += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3;
value += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3;
value += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
value += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3;
value += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
return value;
}
@@ -27,33 +27,37 @@ function transformLon(x, y) {
// source coordinates remain WGS84.
function gcj02ToWgs84(coordinate) {
const [longitude, latitude] = coordinate;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error("Reference coordinate must be finite");
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error('Reference coordinate must be finite');
const dLat = transformLat(longitude - 105, latitude - 35);
const dLon = transformLon(longitude - 105, latitude - 35);
const radLat = latitude / 180 * PI;
const radLat = (latitude / 180) * PI;
const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2;
const sqrtMagic = Math.sqrt(magic);
return [
longitude - dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI),
latitude - dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI),
longitude - (dLon * 180) / ((EARTH_A / sqrtMagic) * Math.cos(radLat) * PI),
latitude - (dLat * 180) / (((EARTH_A * (1 - EARTH_EE)) / (magic * sqrtMagic)) * PI),
];
}
function mapCoordinates(coordinates, mapper) {
if (typeof coordinates[0] === "number") return mapper(coordinates);
if (typeof coordinates[0] === 'number') return mapper(coordinates);
return coordinates.map((value) => mapCoordinates(value, mapper));
}
function convertGeoJson(document) {
if (!document || document.type !== "FeatureCollection" || !Array.isArray(document.features)) {
throw new Error("Reference must be a GeoJSON FeatureCollection");
if (!document || document.type !== 'FeatureCollection' || !Array.isArray(document.features)) {
throw new Error('Reference must be a GeoJSON FeatureCollection');
}
return {
...document,
crs: undefined,
features: document.features.map((feature) => {
if (!feature || !feature.geometry || !feature.geometry.coordinates) throw new Error("Reference feature is missing geometry");
return { ...feature, geometry: { ...feature.geometry, coordinates: mapCoordinates(feature.geometry.coordinates, gcj02ToWgs84) } };
if (!feature || !feature.geometry || !feature.geometry.coordinates)
throw new Error('Reference feature is missing geometry');
return {
...feature,
geometry: { ...feature.geometry, coordinates: mapCoordinates(feature.geometry.coordinates, gcj02ToWgs84) },
};
}),
};
}
@@ -66,7 +70,7 @@ function coordinatesOf(document) {
function walkCoordinates(value, points) {
if (!Array.isArray(value) || !value.length) return;
if (typeof value[0] === "number") {
if (typeof value[0] === 'number') {
points.push(value);
return;
}
@@ -75,7 +79,7 @@ function walkCoordinates(value, points) {
function boundsOf(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 {
minLon: Math.min(...points.map((point) => point[0])),
minLat: Math.min(...points.map((point) => point[1])),
@@ -89,7 +93,7 @@ function centerOf(bounds) {
}
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);
}
@@ -97,13 +101,15 @@ function parseOsmNodes(xml) {
const nodes = [];
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
const attrs = {};
for (const item of match[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[item[1]] = item[2] ?? item[3];
for (const item of match[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
attrs[item[1]] = item[2] ?? item[3];
if (!attrs.id || !Number.isFinite(Number(attrs.lon)) || !Number.isFinite(Number(attrs.lat))) continue;
const tags = {};
for (const item of (match[2] || "").matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
for (const item of (match[2] || '').matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
const tag = {};
for (const attr of item[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) tag[attr[1]] = attr[2] ?? attr[3];
if (tag.k) tags[tag.k] = tag.v || "";
for (const attr of item[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
tag[attr[1]] = attr[2] ?? attr[3];
if (tag.k) tags[tag.k] = tag.v || '';
}
nodes.push({ id: String(attrs.id), coordinate: [Number(attrs.lon), Number(attrs.lat)], tags });
}
@@ -114,12 +120,12 @@ function nearestNode(nodes, coordinate, nodeId) {
if (nodeId) {
const exact = nodes.find((node) => node.id === String(nodeId));
if (!exact) throw new Error(`OSM node not found: ${nodeId}`);
return { ...exact, distanceMeters: distanceMeters(exact.coordinate, coordinate), match: "node-id" };
return { ...exact, distanceMeters: distanceMeters(exact.coordinate, coordinate), match: 'node-id' };
}
const candidates = nodes.map((node) => ({ ...node, distanceMeters: distanceMeters(node.coordinate, coordinate) }));
candidates.sort((first, second) => first.distanceMeters - second.distanceMeters);
if (!candidates[0]) throw new Error("OSM contains no usable nodes");
return { ...candidates[0], match: "nearest-node" };
if (!candidates[0]) throw new Error('OSM contains no usable nodes');
return { ...candidates[0], match: 'nearest-node' };
}
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.
function complexClusterSurface(nativeRoadSurfaceFile, node, clusterId) {
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 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;
const core = clusterId
? cores.find((item) => String(item.properties.cluster_id) === String(clusterId))
: [...cores].sort((first, second) => distanceMeters(first.properties.center, node.coordinate) - distanceMeters(second.properties.center, node.coordinate))[0];
: [...cores].sort(
(first, second) =>
distanceMeters(first.properties.center, node.coordinate) -
distanceMeters(second.properties.center, node.coordinate),
)[0];
if (!core) return null;
const features = parts.filter((item) => item.properties.cluster_id === core.properties.cluster_id);
return { clusterId: core.properties.cluster_id, core, features };
}
function inspectReference({ referenceFile, osmFile, nativeIntersectionFile, nativeRoadSurfaceFile, nodeId, clusterId }) {
const source = JSON.parse(fs.readFileSync(referenceFile, "utf8"));
function inspectReference({
referenceFile,
osmFile,
nativeIntersectionFile,
nativeRoadSurfaceFile,
nodeId,
clusterId,
}) {
const source = JSON.parse(fs.readFileSync(referenceFile, 'utf8'));
const converted = convertGeoJson(source);
const referenceBounds = boundsOf(converted);
const referenceCenter = centerOf(referenceBounds);
const nodes = parseOsmNodes(fs.readFileSync(osmFile, "utf8"));
const nodes = parseOsmNodes(fs.readFileSync(osmFile, 'utf8'));
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 cluster = feature ? null : complexClusterSurface(nativeRoadSurfaceFile, matchedNode, clusterId);
const matchedFeatures = feature ? [feature] : cluster?.features || null;
const nativeBounds = matchedFeatures ? boundsOf({ features: matchedFeatures }) : null;
const diagnostics = [];
if (!matchedFeatures) diagnostics.push(nativeRoadSurfaceFile ? "No native intersection surface or complex cluster matched the OSM node" : "No native intersection surface matched the OSM node; pass --native-road-surface to also search complex junction clusters");
if (!matchedFeatures)
diagnostics.push(
nativeRoadSurfaceFile
? 'No native intersection surface or complex cluster matched the OSM node'
: 'No native intersection surface matched the OSM node; pass --native-road-surface to also search complex junction clusters',
);
return {
schema: "gaode-junction-reference-comparison/v2",
source: { file: referenceFile, coordinateSystem: "GCJ-02", featureCount: converted.features.length },
conversion: { target: "WGS84", method: "gcj02-inverse-approximation" },
schema: 'gaode-junction-reference-comparison/v2',
source: { file: referenceFile, coordinateSystem: 'GCJ-02', featureCount: converted.features.length },
conversion: { target: 'WGS84', method: 'gcj02-inverse-approximation' },
reference: { bounds: referenceBounds, center: referenceCenter },
matchedOsmNode: { id: matchedNode.id, coordinate: matchedNode.coordinate, tags: matchedNode.tags, match: matchedNode.match, centerDistanceMeters: matchedNode.distanceMeters },
nativeIntersection: nativeBounds ? {
kind: feature ? "junction-node" : "complex-cluster",
clusterId: cluster?.clusterId || null,
featureCount: matchedFeatures.length,
bounds: nativeBounds,
bboxIoU: bboxIntersectionRatio(referenceBounds, nativeBounds),
centerOffsetMeters: distanceMeters(referenceCenter, centerOf(nativeBounds)),
featureProperties: feature ? feature.properties : cluster.core.properties,
} : null,
matchedOsmNode: {
id: matchedNode.id,
coordinate: matchedNode.coordinate,
tags: matchedNode.tags,
match: matchedNode.match,
centerDistanceMeters: matchedNode.distanceMeters,
},
nativeIntersection: nativeBounds
? {
kind: feature ? 'junction-node' : 'complex-cluster',
clusterId: cluster?.clusterId || null,
featureCount: matchedFeatures.length,
bounds: nativeBounds,
bboxIoU: bboxIntersectionRatio(referenceBounds, nativeBounds),
centerOffsetMeters: distanceMeters(referenceCenter, centerOf(nativeBounds)),
featureProperties: feature ? feature.properties : cluster.core.properties,
}
: null,
diagnostics,
converted,
matchedFeatures,
@@ -186,10 +218,10 @@ function inspectReference({ referenceFile, osmFile, nativeIntersectionFile, nati
function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters = 180 }) {
const width = 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) => [
width / 2 + (point[0] - center[0]) * lonScale * width / (radiusMeters * 2),
height / 2 - (point[1] - center[1]) * 111320 * height / (radiusMeters * 2),
width / 2 + ((point[0] - center[0]) * lonScale * width) / (radiusMeters * 2),
height / 2 - ((point[1] - center[1]) * 111320 * height) / (radiusMeters * 2),
];
const pathFor = (coordinates) => {
const parts = [];
@@ -201,23 +233,30 @@ function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters
const [x, y] = project(point);
parts.push(`L ${x.toFixed(1)} ${y.toFixed(1)}`);
}
if (close) parts.push("Z");
if (close) parts.push('Z');
};
const visit = (value) => {
if (!Array.isArray(value) || !value.length) return;
if (typeof value[0] === "number") return;
if (typeof value[0][0] === "number") appendLine(value, value.length > 2);
if (typeof value[0] === 'number') return;
if (typeof value[0][0] === 'number') appendLine(value, value.length > 2);
else value.forEach(visit);
};
visit(coordinates);
return parts.join(" ");
return parts.join(' ');
};
const color = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" };
const references = converted.features.map((feature) => {
const type = feature.properties?.type || "unknown";
return `<path d="${pathFor(feature.geometry.coordinates)}" fill="${feature.geometry.type.includes("Polygon") ? `${color[type] || "#334155"}18` : "none"}" stroke="${color[type] || "#334155"}" stroke-width="1.2"/>`;
}).join("\n");
const nativePaths = (nativeIntersection?.features || []).map((feature) => `<path d="${pathFor(feature.geometry.coordinates)}" fill="#dc262655" stroke="#dc2626" stroke-width="3"/>`).join("\n");
const color = { 1: '#2563eb', 2: '#0f766e', 3: '#7c3aed', 4: '#ea580c', 5: '#64748b' };
const references = converted.features
.map((feature) => {
const type = feature.properties?.type || 'unknown';
return `<path d="${pathFor(feature.geometry.coordinates)}" fill="${feature.geometry.type.includes('Polygon') ? `${color[type] || '#334155'}18` : 'none'}" stroke="${color[type] || '#334155'}" stroke-width="1.2"/>`;
})
.join('\n');
const nativePaths = (nativeIntersection?.features || [])
.map(
(feature) =>
`<path d="${pathFor(feature.geometry.coordinates)}" fill="#dc262655" stroke="#dc2626" stroke-width="3"/>`,
)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#f8fafc"/>
@@ -228,4 +267,12 @@ function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters
</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 crypto = require("crypto");
const { parseOsm } = require("./osm");
const fs = require('fs');
const crypto = require('crypto');
const { parseOsm } = require('./osm');
const EARTH_RADIUS = 6371008.8;
const CURB_OFFSET_METERS = 5.2;
const MAST_REACH_METERS = 4.5;
const SIGNAL_LAYOUT = Object.freeze({
poleHeightMeters: 6.7, poleRadiusMeters: 0.13, armWidthMeters: 0.21,
mastHeightMeters: 6.25, headCenterHeightMeters: 6.25,
headWidthMeters: 0.68, headDepthMeters: 0.30, headBodyHeightMeters: 1.62,
lensRadiusMeters: 0.22, lensDepthMeters: 0.07, lensFaceOffsetMeters: 0.18,
poleHeightMeters: 6.7,
poleRadiusMeters: 0.13,
armWidthMeters: 0.21,
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],
countdownLateralMeters: 1.15, countdownFaceOffsetMeters: 0.05,
countdownWidthMeters: 0.82, countdownDepthMeters: 0.14,
countdownHeightMeters: 0.56, countdownVerticalOffsetMeters: 0.0,
countdownLateralMeters: 1.15,
countdownFaceOffsetMeters: 0.05,
countdownWidthMeters: 0.82,
countdownDepthMeters: 0.14,
countdownHeightMeters: 0.56,
countdownVerticalOffsetMeters: 0.0,
});
function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
const centers = (intersections.features || []).map((feature, index) => {
const point = polygonCenter(feature.geometry);
return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) };
}).filter((entry) => entry.point);
const centers = (intersections.features || [])
.map((feature, index) => {
const point = polygonCenter(feature.geometry);
return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) };
})
.filter((entry) => entry.point);
const clusteredStops = new Map();
for (const feature of stopLines.features || []) {
const clusterId = feature.properties?.cluster_id;
@@ -33,27 +45,39 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
}
for (const [clusterId, points] of clusteredStops) {
if (points.length < 3) continue;
const point = points.reduce((sum, item) => [sum[0] + item[0] / points.length, sum[1] + item[1] / points.length], [0, 0]);
centers.push({ id: `cluster-${clusterId}`, clusterId, point, radius: Math.max(...points.map((item) => metersBetween(point, item))) });
const point = points.reduce(
(sum, item) => [sum[0] + item[0] / points.length, sum[1] + item[1] / points.length],
[0, 0],
);
centers.push({
id: `cluster-${clusterId}`,
clusterId,
point,
radius: Math.max(...points.map((item) => metersBetween(point, item))),
});
}
const candidates = [];
for (const feature of stopLines.features || []) {
const center = polygonCenter(feature.geometry);
if (!center) continue;
const clusterId = feature.properties?.cluster_id;
const intersection = clusterId ? centers.find((entry) => entry.clusterId === clusterId) : nearestCenter(center, centers);
const intersection = clusterId
? centers.find((entry) => entry.clusterId === clusterId)
: nearestCenter(center, centers);
if (!intersection || metersBetween(center, intersection.point) > 32) continue;
const axis = roadAxis(feature.geometry, center, intersection.point);
if (!axis) continue;
const right = [axis[1], -axis[0]];
candidates.push({
intersectionId: intersection.id, center, axis,
intersectionId: intersection.id,
center,
axis,
point: intersection.clusterId
? moveMeters(center, right, CURB_OFFSET_METERS)
: moveMeters(moveMeters(intersection.point, axis, intersection.radius + 3.2), right, CURB_OFFSET_METERS),
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
headingDegrees: normalizeDegrees((Math.atan2(axis[0], axis[1]) * 180) / Math.PI),
matchHeadingDegrees: intersection.clusterId
? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI)
? normalizeDegrees((Math.atan2(-axis[0], -axis[1]) * 180) / Math.PI)
: null,
});
}
@@ -63,158 +87,212 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
if (!controlPoint.every(Number.isFinite) || !Array.isArray(control.arms) || control.arms.length < 3) continue;
const intersection = nearestCenter(controlPoint, centers);
if (!intersection || metersBetween(controlPoint, intersection.point) > 32) continue;
const arms = matchOsmArms(candidates.filter((item) => item.intersectionId === intersection.id), controlPoint, control.arms);
const arms = matchOsmArms(
candidates.filter((item) => item.intersectionId === intersection.id),
controlPoint,
control.arms,
);
const groups = phaseGroups(arms);
arms.forEach((candidate, index) => {
const fallbackArmId = `heading-${Math.round(normalizeDegrees(candidate.osmArm?.headingDegrees || 0) * 1000)}`;
const sourceWayId = String(candidate.osmArm?.wayId || "legacy");
const sourceWayId = String(candidate.osmArm?.wayId || 'legacy');
const neighborNodeId = String(candidate.osmArm?.neighborNodeId || fallbackArmId);
const approachId = `${sourceWayId}:${neighborNodeId}`;
const signalUid = `osm-${String(control.id)}-${sourceWayId}-${neighborNodeId}`;
features.push({
type: "Feature",
geometry: { type: "Point", coordinates: candidate.point.slice() },
type: 'Feature',
geometry: { type: 'Point', coordinates: candidate.point.slice() },
properties: {
signal_uid: signalUid, display_id: signalUid, control_id: String(control.id),
approach_id: approachId, source_way_id: sourceWayId,
signal_uid: signalUid,
display_id: signalUid,
control_id: String(control.id),
approach_id: approachId,
source_way_id: sourceWayId,
// These are independent assembly controls. heading_deg remains a
// migration hint for older native documents only.
mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90),
face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180),
phase_group: groups[index],
mast_reach_m: MAST_REACH_METERS,
stop_lon: candidate.center[0], stop_lat: candidate.center[1],
enabled: true, z_offset_m: 0,
stop_lon: candidate.center[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) {
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
throw new Error("Traffic signal assemblies must be a FeatureCollection");
if (collection?.type !== 'FeatureCollection' || !Array.isArray(collection.features)) {
throw new Error('Traffic signal assemblies must be a FeatureCollection');
}
const uids = new Set();
const displayIds = new Set();
const features = collection.features.map((feature, index) => {
const label = `traffic signal feature ${index + 1}`;
if (feature?.geometry?.type !== "Point" || !Array.isArray(feature.geometry.coordinates) ||
feature.geometry.coordinates.length < 2 || !feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)) {
if (
feature?.geometry?.type !== 'Point' ||
!Array.isArray(feature.geometry.coordinates) ||
feature.geometry.coordinates.length < 2 ||
!feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)
) {
throw new Error(`${label}: geometry must be a finite Point`);
}
const input = feature.properties || {};
const text = (key, required = true) => {
const value = input[key] == null ? "" : String(input[key]).trim();
const value = input[key] == null ? '' : String(input[key]).trim();
if (required && !value) throw new Error(`${label}: missing ${key}`);
return value;
};
const number = (key, options = {}) => {
if (input[key] === null || input[key] === undefined || input[key] === "") {
if (input[key] === null || input[key] === undefined || input[key] === '') {
throw new Error(`${label}: missing ${key}`);
}
const value = Number(input[key]);
if (!Number.isFinite(value) || (options.min != null && value < options.min) || (options.max != null && value > options.max)) {
if (
!Number.isFinite(value) ||
(options.min != null && value < options.min) ||
(options.max != null && value > options.max)
) {
throw new Error(`${label}: invalid ${key} '${input[key]}'`);
}
return value;
};
const signalUid = text("signal_uid");
const signalUid = text('signal_uid');
if (!/^osm-[A-Za-z0-9_.:-]+$/.test(signalUid)) throw new Error(`${label}: invalid signal_uid '${signalUid}'`);
if (uids.has(signalUid)) throw new Error(`Duplicate signal_uid '${signalUid}'`);
uids.add(signalUid);
const displayId = text("display_id", false);
const displayId = text('display_id', false);
if (displayId && displayIds.has(displayId)) throw new Error(`Duplicate display_id '${displayId}'`);
if (displayId) displayIds.add(displayId);
const phaseGroup = number("phase_group", { min: 0, max: 1 });
const phaseGroup = number('phase_group', { min: 0, max: 1 });
if (!Number.isInteger(phaseGroup)) throw new Error(`${label}: phase_group must be 0 or 1`);
const enabled = normalizeBoolean(input.enabled, label);
const controlId = text("control_id");
const approachId = text("approach_id");
const sourceWayId = text("source_way_id");
if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`);
const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`;
if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`);
const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg"));
if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) {
const controlId = text('control_id');
const approachId = text('approach_id');
const sourceWayId = text('source_way_id');
if (!approachId.startsWith(`${sourceWayId}:`))
throw new Error(`${label}: approach_id does not match source_way_id`);
const expectedUid = `osm-${controlId}-${approachId.replace(':', '-')}`;
if (signalUid !== expectedUid)
throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`);
const legacyHeading =
input.heading_deg == null || input.heading_deg === '' ? null : normalizeDegrees(number('heading_deg'));
if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === '')) {
throw new Error(`${label}: missing mast_heading_deg`);
}
if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) {
if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === '')) {
throw new Error(`${label}: missing face_heading_deg`);
}
const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === ""
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90)
: normalizeDegrees(number("mast_heading_deg"));
const faceHeading = input.face_heading_deg == null || input.face_heading_deg === ""
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180)
: normalizeDegrees(number("face_heading_deg"));
const mastHeading =
input.mast_heading_deg == null || input.mast_heading_deg === ''
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90)
: normalizeDegrees(number('mast_heading_deg'));
const faceHeading =
input.face_heading_deg == null || input.face_heading_deg === ''
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180)
: normalizeDegrees(number('face_heading_deg'));
return {
type: "Feature",
geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) },
type: 'Feature',
geometry: { type: 'Point', coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) },
properties: {
...input, signal_uid: signalUid, display_id: displayId,
control_id: controlId, approach_id: approachId,
...input,
signal_uid: signalUid,
display_id: displayId,
control_id: controlId,
approach_id: approachId,
source_way_id: sourceWayId,
// Retain the legacy value only for migration compatibility. Runtime
// geometry is entirely defined by mast_heading_deg and face_heading_deg.
heading_deg: legacyHeading,
mast_heading_deg: mastHeading, face_heading_deg: faceHeading,
phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }),
stop_lon: number("stop_lon", { min: -180, max: 180 }),
stop_lat: number("stop_lat", { min: -90, max: 90 }),
enabled, z_offset_m: number("z_offset_m", { min: -20, max: 100 }),
mast_heading_deg: mastHeading,
face_heading_deg: faceHeading,
phase_group: phaseGroup,
mast_reach_m: number('mast_reach_m', { min: 0.1, max: 30 }),
stop_lon: number('stop_lon', { min: -180, max: 180 }),
stop_lat: number('stop_lat', { min: -90, max: 90 }),
enabled,
z_offset_m: number('z_offset_m', { min: -20, max: 100 }),
},
};
});
return { type: "FeatureCollection", features };
return { type: 'FeatureCollection', features };
}
function buildTrafficSignalsFromFeatures(collection) {
const normalized = validateTrafficSignalFeatures(collection);
const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => {
const p = feature.properties;
const point = feature.geometry.coordinates;
const mastAxis = headingVector(p.mast_heading_deg);
return {
id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id,
nodeKey: signalNodeKey(p.signal_uid),
controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id,
phaseGroup: p.phase_group, longitude: point[0], latitude: point[1],
stopLongitude: p.stop_lon, stopLatitude: p.stop_lat,
// Existing Blender readers require headingDegrees. It is a compatibility
// alias only; the independent mast/face fields below define all geometry.
headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg,
mastHeadingDegrees: p.mast_heading_deg,
faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m,
zOffsetMeters: p.z_offset_m,
pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m),
};
});
const signals = normalized.features
.filter((feature) => feature.properties.enabled)
.map((feature) => {
const p = feature.properties;
const point = feature.geometry.coordinates;
const mastAxis = headingVector(p.mast_heading_deg);
return {
id: p.signal_uid,
signalUid: p.signal_uid,
displayId: p.display_id,
nodeKey: signalNodeKey(p.signal_uid),
controlId: p.control_id,
approachId: p.approach_id,
sourceWayId: p.source_way_id,
phaseGroup: p.phase_group,
longitude: point[0],
latitude: point[1],
stopLongitude: p.stop_lon,
stopLatitude: p.stop_lat,
// Existing Blender readers require headingDegrees. It is a compatibility
// alias only; the independent mast/face fields below define all geometry.
headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg,
mastHeadingDegrees: p.mast_heading_deg,
faceHeadingDegrees: p.face_heading_deg,
mastReachMeters: p.mast_reach_m,
zOffsetMeters: p.z_offset_m,
pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m),
};
});
return { version: 3, layout: SIGNAL_LAYOUT, signals };
}
function signalNodeKey(signalUid) {
return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`;
return `ts_${crypto.createHash('sha256').update(signalUid).digest('hex').slice(0, 16)}`;
}
function reconcileTrafficSignalSourceReferences(collection, controls) {
const normalized = validateTrafficSignalFeatures(collection);
const approachesByControl = new Map((controls || []).map((control) => [
String(control.id),
new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)),
]));
const approachesByControl = new Map(
(controls || []).map((control) => [
String(control.id),
new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)),
]),
);
const kept = [];
const dropped = [];
for (const [index, feature] of normalized.features.entries()) {
const { control_id: controlId, approach_id: approachId, signal_uid: signalUid } = feature.properties;
const approaches = approachesByControl.get(controlId);
if (!approaches) {
dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-control", message: `control_id '${controlId}' is not present in the current OSM` });
dropped.push({
index: index + 1,
signalUid,
controlId,
approachId,
reason: 'missing-control',
message: `control_id '${controlId}' is not present in the current OSM`,
});
continue;
}
if (!approaches.has(approachId)) {
dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-approach", message: `approach_id '${approachId}' is not present on OSM control '${controlId}'` });
dropped.push({
index: index + 1,
signalUid,
controlId,
approachId,
reason: 'missing-approach',
message: `approach_id '${approachId}' is not present on OSM control '${controlId}'`,
});
continue;
}
kept.push(feature);
@@ -233,67 +311,119 @@ function buildTrafficSignals(stopLines, intersections, controls = []) {
}
function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) {
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
const controls = parseOsm(fs.readFileSync(osmPath, 'utf8')).trafficSignalControls;
return buildTrafficSignalFeatures(
JSON.parse(fs.readFileSync(stopLinePath, "utf8")),
JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls,
JSON.parse(fs.readFileSync(stopLinePath, 'utf8')),
JSON.parse(fs.readFileSync(intersectionPath, 'utf8')),
controls,
);
}
function readTrafficSignals(editablePath, osmPath = null) {
const collection = JSON.parse(fs.readFileSync(editablePath, "utf8"));
const collection = JSON.parse(fs.readFileSync(editablePath, 'utf8'));
if (osmPath) {
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
const controls = parseOsm(fs.readFileSync(osmPath, 'utf8')).trafficSignalControls;
validateTrafficSignalSourceReferences(collection, controls);
}
return buildTrafficSignalsFromFeatures(collection);
}
function normalizeBoolean(value, label) {
if (value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true" || String(value).toLowerCase() === "yes") return true;
if (value === false || value === 0 || value === "0" || String(value).toLowerCase() === "false" || String(value).toLowerCase() === "no") return false;
if (
value === true ||
value === 1 ||
value === '1' ||
String(value).toLowerCase() === 'true' ||
String(value).toLowerCase() === 'yes'
)
return true;
if (
value === false ||
value === 0 ||
value === '0' ||
String(value).toLowerCase() === 'false' ||
String(value).toLowerCase() === 'no'
)
return false;
throw new Error(`${label}: invalid enabled '${value}'`);
}
function uniqueApproachArms(candidates, controlPoint) {
const sorted = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)), controlDistance: metersBetween(controlPoint, candidate.center) }))
const sorted = candidates
.map((candidate) => ({
...candidate,
armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)),
controlDistance: metersBetween(controlPoint, candidate.center),
}))
.sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance);
const arms = [];
for (const candidate of sorted) if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate);
for (const candidate of sorted)
if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate);
return arms;
}
function matchOsmArms(candidates, controlPoint, osmArms) {
const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: candidate.matchHeadingDegrees ?? normalizeDegrees(headingBetween(controlPoint, candidate.center)) }));
const remaining = candidates.map((candidate) => ({
...candidate,
armHeading: candidate.matchHeadingDegrees ?? normalizeDegrees(headingBetween(controlPoint, candidate.center)),
}));
if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint);
return osmArms.map((osmArm) => {
let bestIndex = -1; let bestDistance = Infinity;
let bestIndex = -1;
let bestDistance = Infinity;
remaining.forEach((item, index) => {
const directedDistance = angularDistance(item.armHeading, osmArm.headingDegrees);
const distance = item.matchHeadingDegrees == null
? directedDistance
: Math.min(
angularDistance(item.matchHeadingDegrees, osmArm.headingDegrees),
angularDistance(item.matchHeadingDegrees + 180, osmArm.headingDegrees),
);
if (distance < bestDistance) { bestDistance = distance; bestIndex = index; }
const distance =
item.matchHeadingDegrees == null
? directedDistance
: Math.min(
angularDistance(item.matchHeadingDegrees, osmArm.headingDegrees),
angularDistance(item.matchHeadingDegrees + 180, osmArm.headingDegrees),
);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
});
const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm);
const candidate =
bestIndex >= 0 && bestDistance <= 45
? remaining.splice(bestIndex, 1)[0]
: fallbackCandidate(controlPoint, osmArm);
return { ...candidate, osmArm };
});
}
function fallbackCandidate(controlPoint, osmArm) {
const outward = headingVector(osmArm.headingDegrees); const axis = [-outward[0], -outward[1]];
const center = moveMeters(controlPoint, outward, 8); const farSide = moveMeters(controlPoint, axis, 3.2);
return { center, axis, point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS), armHeading: normalizeDegrees(osmArm.headingDegrees), headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), fallback: true };
const outward = headingVector(osmArm.headingDegrees);
const axis = [-outward[0], -outward[1]];
const center = moveMeters(controlPoint, outward, 8);
const farSide = moveMeters(controlPoint, axis, 3.2);
return {
center,
axis,
point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS),
armHeading: normalizeDegrees(osmArm.headingDegrees),
headingDegrees: normalizeDegrees((Math.atan2(axis[0], axis[1]) * 180) / Math.PI),
fallback: true,
};
}
function phaseGroups(arms) {
const groups = Array(arms.length).fill(1); if (arms.length < 2) return groups;
let main = [0, 1]; let best = -1;
for (let a = 0; a < arms.length; a += 1) for (let b = a + 1; b < arms.length; b += 1) { const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading); if (opposition > best) { best = opposition; main = [a, b]; } }
groups[main[0]] = 0; groups[main[1]] = 0; return groups;
const groups = Array(arms.length).fill(1);
if (arms.length < 2) return groups;
let main = [0, 1];
let best = -1;
for (let a = 0; a < arms.length; a += 1)
for (let b = a + 1; b < arms.length; b += 1) {
const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading);
if (opposition > best) {
best = opposition;
main = [a, b];
}
}
groups[main[0]] = 0;
groups[main[1]] = 0;
return groups;
}
function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) {
@@ -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 lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
const faceRight = [-face[1], face[0]];
const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters);
return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } };
const board = moveMeters(
moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters),
face,
SIGNAL_LAYOUT.countdownFaceOffsetMeters,
);
return {
pole: position(pole, 0),
arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) },
head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees },
lenses: ['red', 'yellow', 'green'].map((state, index) => ({
state,
...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]),
})),
countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees },
};
}
function polygonCenter(geometry) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; if (!ring || ring.length < 4) return null; const points = ring.slice(0, -1); return [points.reduce((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length]; }
function polygonRadius(geometry, center) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0; }
function roadAxis(geometry, center, target) { const ring = geometry?.coordinates?.[0]; if (!ring || ring.length < 3) return null; let longest; for (let i = 0; i < ring.length - 1; i += 1) { const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180); const dy = ring[i + 1][1] - ring[i][1]; const length = Math.hypot(dx, dy); if (!longest || length > longest.length) longest = { dx, dy, length }; } if (!longest?.length) return null; let axis = [-longest.dy / longest.length, longest.dx / longest.length]; const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]]; if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]]; return axis; }
function nearestCenter(point, centers) { return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null; }
function metersBetween(a, b) { const lat = (a[1] + b[1]) / 2 * Math.PI / 180; return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS; }
function moveMeters(point, vector, meters) { const scale = 180 / Math.PI / EARTH_RADIUS; return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale]; }
function headingBetween(from, to) { const latitude = (from[1] + to[1]) / 2 * Math.PI / 180; return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI; }
function headingVector(degrees) { const radians = degrees * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; }
function normalizeDegrees(value) { return ((value % 360) + 360) % 360; }
function angularDistance(a, b) { return Math.abs(((a - b + 540) % 360) - 180); }
function polygonCenter(geometry) {
const ring = geometry?.type === 'Polygon' ? geometry.coordinates?.[0] : null;
if (!ring || ring.length < 4) return null;
const points = ring.slice(0, -1);
return [points.reduce((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length];
}
function polygonRadius(geometry, center) {
const ring = geometry?.type === 'Polygon' ? geometry.coordinates?.[0] : null;
return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0;
}
function roadAxis(geometry, center, target) {
const ring = geometry?.coordinates?.[0];
if (!ring || ring.length < 3) return null;
let longest;
for (let i = 0; i < ring.length - 1; i += 1) {
const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos((center[1] * Math.PI) / 180);
const dy = ring[i + 1][1] - ring[i][1];
const length = Math.hypot(dx, dy);
if (!longest || length > longest.length) longest = { dx, dy, length };
}
if (!longest?.length) return null;
let axis = [-longest.dy / longest.length, longest.dx / longest.length];
const toward = [(target[0] - center[0]) * Math.cos((center[1] * Math.PI) / 180), target[1] - center[1]];
if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]];
return axis;
}
function nearestCenter(point, centers) {
return (
centers
.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) }))
.sort((a, b) => a.distance - b.distance)[0] || null
);
}
function metersBetween(a, b) {
const lat = (((a[1] + b[1]) / 2) * Math.PI) / 180;
return ((Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI) / 180) * EARTH_RADIUS;
}
function moveMeters(point, vector, meters) {
const scale = 180 / Math.PI / EARTH_RADIUS;
return [
point[0] + (vector[0] * meters * scale) / Math.cos((point[1] * Math.PI) / 180),
point[1] + vector[1] * meters * scale,
];
}
function headingBetween(from, to) {
const latitude = (((from[1] + to[1]) / 2) * Math.PI) / 180;
return (Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180) / Math.PI;
}
function headingVector(degrees) {
const radians = (degrees * Math.PI) / 180;
return [Math.sin(radians), Math.cos(radians)];
}
function normalizeDegrees(value) {
return ((value % 360) + 360) % 360;
}
function angularDistance(a, b) {
return Math.abs(((a - b + 540) % 360) - 180);
}
module.exports = {
SIGNAL_LAYOUT,

View File

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

View File

@@ -101,8 +101,5 @@
"bytes": 74151
}
},
"volatileExcluded": [
"absolute paths -> <repo> or <external>",
"native-road-* staging directory -> <staging>"
]
"volatileExcluded": ["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 crypto = require("crypto");
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const { unzipSync, strFromU8 } = require("fflate");
const { compiler } = require("../src");
const { exportNativeRoadPackage } = require("../src/export/native-road-package");
const assert = require('assert/strict');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { unzipSync, strFromU8 } = require('fflate');
const { compiler } = require('../src');
const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const root = path.resolve(__dirname, "..");
for (const areaId of ["fengshu-er-road", "nantaizi-lake-innovation-valley"]) {
const isFengshu = areaId === "fengshu-er-road";
const outputRoot = path.join(root, "outputs", areaId);
const root = path.resolve(__dirname, '..');
for (const areaId of ['fengshu-er-road', 'nantaizi-lake-innovation-valley']) {
const isFengshu = areaId === 'fengshu-er-road';
const outputRoot = path.join(root, 'outputs', areaId);
const input = {
areaId,
osmFile: path.join(root, "inputs", "osm", isFengshu ? "枫树二路.osm" : "南台子湖创新谷OSM.osm"),
outDir: path.join(outputRoot, "native-road"),
stagingDir: path.join(outputRoot, "_pipeline"),
overridesFile: path.join(outputRoot, "native-road-overrides.json"),
trafficSignalsFile: path.join(outputRoot, "native-traffic-signals.json"),
comparisonDir: path.join(outputRoot, "osm2streets_web_out"),
osmFile: path.join(root, 'inputs', 'osm', isFengshu ? '枫树二路.osm' : '南台子湖创新谷OSM.osm'),
outDir: path.join(outputRoot, 'native-road'),
stagingDir: path.join(outputRoot, '_pipeline'),
overridesFile: path.join(outputRoot, 'native-road-overrides.json'),
trafficSignalsFile: path.join(outputRoot, 'native-traffic-signals.json'),
comparisonDir: path.join(outputRoot, 'osm2streets_web_out'),
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);
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 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 manifest = JSON.parse(strFromU8(entries["manifest.json"]));
const manifest = JSON.parse(strFromU8(entries['manifest.json']));
assert.equal(manifest.areaId, areaId);
assert.equal(manifest.contract, "native-road-package/v1.1");
assert.deepEqual(manifest.generator, { name: "road-compiler", version: "0.3.0" });
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(manifest.contract, 'native-road-package/v1.1');
assert.deepEqual(manifest.generator, { name: 'road-compiler', version: '0.3.0' });
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).length, 18);
if (isFengshu) {
const inputFile = path.join(outputRoot, "road-compiler-input.json");
const archive = path.join(outputRoot, "export.zip");
const inputFile = path.join(outputRoot, 'road-compiler-input.json');
const archive = path.join(outputRoot, 'export.zip');
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));
}
}
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 fs = require("fs");
const path = require("path");
const compiler = require("../src");
const assert = require('assert/strict');
const fs = require('fs');
const path = require('path');
const compiler = require('../src');
assert.equal(typeof compiler.laneGeometry.laneCenterline, "function");
assert.equal(typeof compiler.gaodeReference.convertGeoJson, "function");
assert.equal(typeof compiler.turnLaneArrows.buildCustomTurnLaneArrows, "function");
assert.equal(typeof compiler.complexJunction.buildComplexJunctionGeometry, "function");
assert.equal(typeof compiler.nativeRoad.compileRoadModel, "function");
assert.equal(typeof compiler.check.checkOutput, "function");
assert.equal(typeof compiler.layerManifest.manifestForArea, "function");
const manifest = compiler.layerManifest.manifestForArea("fixture");
assert.equal(manifest.contract, "native-road-package/v1.1");
assert.equal(typeof compiler.laneGeometry.laneCenterline, 'function');
assert.equal(typeof compiler.gaodeReference.convertGeoJson, 'function');
assert.equal(typeof compiler.turnLaneArrows.buildCustomTurnLaneArrows, 'function');
assert.equal(typeof compiler.complexJunction.buildComplexJunctionGeometry, 'function');
assert.equal(typeof compiler.nativeRoad.compileRoadModel, 'function');
assert.equal(typeof compiler.check.checkOutput, 'function');
assert.equal(typeof compiler.layerManifest.manifestForArea, 'function');
const manifest = compiler.layerManifest.manifestForArea('fixture');
assert.equal(manifest.contract, 'native-road-package/v1.1');
assert.equal(manifest.layers.length, 12);
assert.deepEqual(manifest.layers.filter((layer) => layer.role === "semantic").map((layer) => layer.source), ["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.deepEqual(
manifest.layers.filter((layer) => layer.role === 'semantic').map((layer) => layer.source),
['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.throws(() => compiler.compiler.validateInput({ id: "area" }), /RoadCompilerInput/);
const model = compiler.nativeRoad.compileRoadModel(fs.readFileSync(fixture, "utf8"), { schema: "native-road-overrides/v1", overrides: [] });
assert.throws(() => compiler.compiler.validateInput({ id: 'area' }), /RoadCompilerInput/);
const model = compiler.nativeRoad.compileRoadModel(fs.readFileSync(fixture, 'utf8'), {
schema: 'native-road-overrides/v1',
overrides: [],
});
assert.equal(model.roads.length, 24);
console.log("road compiler package tests passed");
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>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/vendor/ol/ol.css"><link rel="stylesheet" href="/app.css"></head>
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><span id="dirty-state" aria-live="polite"></span><label style="display:inline;margin:0 0 0 auto;white-space:nowrap"><input id="scene-preview" type="checkbox"> 场景效果</label><a id="export-package" href="/api/export.zip" download>导出道路包</a><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
<main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="sidewalks" type="checkbox" checked> 路缘与步行带</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="gaodeReference" type="checkbox" checked> 高德规整路口参考</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道、转向路径或路口面以查看详情。</p><section id="selected-junction" hidden><h2>当前路口</h2><output id="junction-detail"></output></section><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.01"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有路缘与步行带</label><label><input id="right" type="checkbox"> 右侧有路缘与步行带</label><button type="submit">暂存本道路修改</button></form><form id="center-line-form" hidden><h2 id="marking-style-heading">道路中心线样式</h2><output id="center-line-segment"></output><label>样式<select id="center-line-style"><option value="yellow-dashed">黄色虚线(默认)</option><option value="white-dashed">白色虚线</option><option value="yellow-solid">黄色实线</option><option value="white-solid">白色实线</option></select></label><button type="submit">暂存标线样式</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence"></pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html>
<html lang="zh-CN">
<head>
<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
"use strict";
'use strict';
const fs = require("fs");
const http = require("http");
const path = require("path");
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../src/compile/native-road");
const { generate, validateDocument, runtime } = require("../src/native-traffic-signals");
const { convertGeoJson } = require("../src/reference/gaode");
const { exportNativeRoadPackage } = require("../src/export/native-road-package");
const { compileInput } = require("../src/compile/compiler");
const fs = require('fs');
const http = require('http');
const path = require('path');
const { loadOverrides, validateOverrides, writeJsonAtomic } = require('../src/compile/native-road');
const { generate, validateDocument, runtime } = require('../src/native-traffic-signals');
const { convertGeoJson } = require('../src/reference/gaode');
const { exportNativeRoadPackage } = require('../src/export/native-road-package');
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 }) {
if (typeof junctionReference === "string") junctionReference = readJunctionReference(junctionReference);
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,
}) {
if (typeof junctionReference === 'string') junctionReference = readJunctionReference(junctionReference);
// `--debug` surfaces advisory compiler findings that have no geometry layer of
// their own — currently the complex-junction candidates. Off by default so the
// normal editing view stays uncluttered.
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
const session = { area, context: { repoRoot, configPath, compileFresh, readAreaConfig }, junctionReference, debug, dataRoot };
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw new Error('--port must be an integer in [1024, 65535].');
const session = {
area,
context: { repoRoot, configPath, compileFresh, readAreaConfig },
junctionReference,
debug,
dataRoot,
};
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();
}
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}`);
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;
}
@@ -35,73 +60,152 @@ function handle(request, response, session) {
const context = session.context;
const junctionReference = session.junctionReference;
const debug = session.debug;
const url = new URL(request.url, "http://127.0.0.1");
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(__dirname, "client", "index.html"), "text/html; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(__dirname, "client", "app.js"), "text/javascript; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(__dirname, "client", "app.css"), "text/css; charset=utf-8");
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname, context.repoRoot);
if (request.method === "GET" && url.pathname === "/api/state") return area ? sendJson(response, 200, state(area, junctionReference, debug)) : sendJson(response, 200, { active: false });
if (request.method === "GET" && url.pathname === "/api/session") return sendJson(response, 200, { active: Boolean(session.area), areaId: session.area?.id || null });
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 }));
if (request.method === "GET" && url.pathname === "/api/export.zip") return Promise.resolve().then(() => {
if (!area) throw new Error("请先导入 OSM 文件。");
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
response.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${area.id}.native-road.zip"`,
"Content-Length": exported.bytes.length,
"Cache-Control": "no-store",
});
response.end(Buffer.from(exported.bytes));
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => {
if (!area) throw new Error("请先导入 OSM 文件。");
const document = validateDocument(body, fs.readFileSync(area.input, "utf8"));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/traffic-signals/generate") return Promise.resolve().then(() => {
if (!area) throw new Error("请先导入 OSM 文件。");
const compiled = readCompiled(area);
const generated = generate(fs.readFileSync(area.input, "utf8"), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "vehicle_stop_lines.geojson")), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "intersection_surface.geojson")));
const current = validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"));
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
current.assemblies.features.push(...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid)));
writeJsonAtomic(area.outputs.nativeTrafficSignals, current);
sendJson(response, 200, { ok: true, trafficSignals: current, runtime: runtime(current), generated: generated.assemblies.features.length, compiled: Boolean(compiled) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
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 }));
sendJson(response, 404, { error: "Not found" });
const url = new URL(request.url, 'http://127.0.0.1');
if (request.method === 'GET' && url.pathname === '/') return sendWorkbenchApp(response);
if (request.method === 'GET' && url.pathname.startsWith('/vendor/'))
return sendVendorFile(response, url.pathname, context.repoRoot);
if (request.method === 'GET' && url.pathname === '/api/state')
return area
? sendJson(response, 200, state(area, junctionReference, debug))
: sendJson(response, 200, { active: false });
if (request.method === 'GET' && url.pathname === '/api/session')
return sendJson(response, 200, { active: Boolean(session.area), areaId: session.area?.id || null });
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 }));
if (request.method === 'GET' && url.pathname === '/api/export.zip')
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
response.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${area.id}.native-road.zip"`,
'Content-Length': exported.bytes.length,
'Cache-Control': 'no-store',
});
response.end(Buffer.from(exported.bytes));
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/traffic-signals')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
const document = validateDocument(body, fs.readFileSync(area.input, 'utf8'));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/traffic-signals/generate')
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
const compiled = readCompiled(area);
const generated = generate(
fs.readFileSync(area.input, 'utf8'),
readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'vehicle_stop_lines.geojson')),
readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'intersection_surface.geojson')),
);
const current = validateDocument(
readJson(area.outputs.nativeTrafficSignals),
fs.readFileSync(area.input, 'utf8'),
);
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
current.assemblies.features.push(
...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid)),
);
writeJsonAtomic(area.outputs.nativeTrafficSignals, current);
sendJson(response, 200, {
ok: true,
trafficSignals: current,
runtime: runtime(current),
generated: generated.assemblies.features.length,
compiled: Boolean(compiled),
});
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/overrides')
return readBody(request)
.then((body) => {
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) {
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)
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"))
: { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } };
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, 'utf8'))
: {
schema: 'native-traffic-signals/v1',
provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] },
};
const trafficRuntime = runtime(trafficSignals);
const compiled = readCompiled(area);
return { areaId: area.id, debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null, compiled, overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), junctionReference, layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: 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
// 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.
function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot) {
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);
if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`);
const raw = readJson(configPath);
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 taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String)));
const clash = candidate.nodeIds.filter((nodeId) => taken.has(String(nodeId)));
if (clash.length) throw new Error(`节点 ${clash.join("、")} 已属于其他复杂路口配置。`);
if (clash.length) throw new Error(`节点 ${clash.join('、')} 已属于其他复杂路口配置。`);
const id = uniqueClusterId(`cluster-${candidate.nodeIds[0]}`, new Set(clusters.map((cluster) => cluster.id)));
const cluster = {
id,
@@ -132,7 +236,13 @@ function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot
outerRadiusExtraMeters: 18,
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`;
fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`);
try {
@@ -148,55 +258,176 @@ function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot
function uniqueClusterId(base, taken) {
if (!taken.has(base)) return base;
for (let suffix = 2; suffix < 100; suffix += 1) if (!taken.has(`${base}-${suffix}`)) return `${base}-${suffix}`;
throw new Error("无法生成唯一的 cluster id。");
throw new Error('无法生成唯一的 cluster id。');
}
function junctionCandidates(compiled) {
return (compiled?.diagnostics || [])
.filter((item) => item.rule === "complex-junction-candidate" && item.suggestedCluster)
.sort((first, second) => second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount || first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters)
.map((item, index) => ({ index: index + 1, id: item.id, message: item.message, coordinate: item.geometry?.coordinates || null, ...item.suggestedCluster }));
.filter((item) => item.rule === 'complex-junction-candidate' && item.suggestedCluster)
.sort(
(first, second) =>
second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount ||
first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters,
)
.map((item, index) => ({
index: index + 1,
id: item.id,
message: item.message,
coordinate: item.geometry?.coordinates || null,
...item.suggestedCluster,
}));
}
function readJunctionReference(file) {
if (!fs.existsSync(file)) throw new Error(`Junction reference not found: ${file}`);
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, "utf8")));
return { source: file, coordinateSystem: "GCJ-02", converted };
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, 'utf8')));
return { source: file, coordinateSystem: 'GCJ-02', converted };
}
function readUpload(request, session) {
return readMultipart(request, 20 * 1024 * 1024).then(({ filename, data }) => {
if (!filename || !/\.osm$/i.test(filename)) 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";
if (!filename || !/\.osm$/i.test(filename)) 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';
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 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 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: [] } },
};
fs.writeFileSync(input.osmFile, data);
fs.writeFileSync(input.overridesFile, 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; }
fs.writeFileSync(
input.overridesFile,
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) => {
const type = request.headers["content-type"] || ""; const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type); if (!match) return reject(new Error("请使用 multipart/form-data 上传 OSM 文件。"));
const boundary = `--${match[1] || match[2]}`; const chunks = []; let size = 0;
request.on("data", (chunk) => { size += chunk.length; 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 sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); }
function readMultipart(request, limit) {
return new Promise((resolve, reject) => {
const type = request.headers['content-type'] || '';
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type);
if (!match) return reject(new Error('请使用 multipart/form-data 上传 OSM 文件。'));
const boundary = `--${match[1] || match[2]}`;
const chunks = [];
let size = 0;
request.on('data', (chunk) => {
size += chunk.length;
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) {
const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname);
if (!match) return sendJson(response, 404, { error: "Not found" });
const root = path.join(repoRoot, "node_modules", match[1]);
if (!match) return sendJson(response, 404, { error: 'Not found' });
const root = path.join(repoRoot, 'node_modules', match[1]);
const file = path.resolve(root, match[2]);
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return sendJson(response, 404, { error: "Not found" });
return sendFile(response, file, file.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile())
return sendJson(response, 404, { error: 'Not found' });
return sendFile(response, file, file.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8');
}
function sendJson(response, status, value) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(`${JSON.stringify(value)}\n`);
}
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 };