feat: migrate workbench to React
This commit is contained in:
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
workbench/client/dist/
|
||||
.trellis/
|
||||
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
37
.trellis/spec/frontend/quality-guidelines.md
Normal file
37
.trellis/spec/frontend/quality-guidelines.md
Normal 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.
|
||||
@@ -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."}
|
||||
32
.trellis/tasks/08-26-react-workbench-migration/design.md
Normal file
32
.trellis/tasks/08-26-react-workbench-migration/design.md
Normal 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.
|
||||
@@ -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."}
|
||||
20
.trellis/tasks/08-26-react-workbench-migration/implement.md
Normal file
20
.trellis/tasks/08-26-react-workbench-migration/implement.md
Normal 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.
|
||||
43
.trellis/tasks/08-26-react-workbench-migration/prd.md
Normal file
43
.trellis/tasks/08-26-react-workbench-migration/prd.md
Normal 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 重构。若本任务同时重做布局,交付周期和回归范围会显著增加。
|
||||
26
.trellis/tasks/08-26-react-workbench-migration/task.json
Normal file
26
.trellis/tasks/08-26-react-workbench-migration/task.json
Normal 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": {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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 })}`,
|
||||
);
|
||||
|
||||
@@ -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
1143
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
41
src/check.js
41
src/check.js
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
26
src/index.js
26
src/index.js
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
48
src/osm.js
48
src/osm.js
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>"]
|
||||
}
|
||||
|
||||
@@ -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>"]
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
8770
test/fixtures/fengshu-junction-reference.geojson
vendored
8770
test/fixtures/fengshu-junction-reference.geojson
vendored
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
638
workbench/client/src/App.tsx
Normal file
638
workbench/client/src/App.tsx
Normal 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;
|
||||
88
workbench/client/src/components/MapCanvas.tsx
Normal file
88
workbench/client/src/components/MapCanvas.tsx
Normal 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="道路地图" />;
|
||||
}
|
||||
40
workbench/client/src/lib/api.ts
Normal file
40
workbench/client/src/lib/api.ts
Normal 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 }),
|
||||
}),
|
||||
};
|
||||
11
workbench/client/src/main.tsx
Normal file
11
workbench/client/src/main.tsx
Normal 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>,
|
||||
);
|
||||
160
workbench/client/src/map/layers.ts
Normal file
160
workbench/client/src/map/layers.ts
Normal 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') }),
|
||||
);
|
||||
}
|
||||
223
workbench/client/src/styles.css
Normal file
223
workbench/client/src/styles.css
Normal 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;
|
||||
}
|
||||
92
workbench/client/src/types/state.ts
Normal file
92
workbench/client/src/types/state.ts
Normal 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;
|
||||
}
|
||||
12
workbench/client/src/ui/button.tsx
Normal file
12
workbench/client/src/ui/button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
workbench/client/tsconfig.json
Normal file
15
workbench/client/tsconfig.json
Normal 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"]
|
||||
}
|
||||
9
workbench/client/vite.config.ts
Normal file
9
workbench/client/vite.config.ts
Normal 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' } },
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user