feat: migrate road workbench to OpenLayers
This commit is contained in:
@@ -112,12 +112,13 @@ npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.js
|
|||||||
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
|
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||||
```
|
```
|
||||||
|
|
||||||
工作台默认先编译,并在 `http://127.0.0.1:8787/` 展示 OSM 道路、原生结果、诊断和每个值的
|
工作台默认先编译,并在 `http://127.0.0.1:8787/` 启动基于 OpenLayers 的 GIS 工作台,展示
|
||||||
来源。可编辑道路宽度、车道数、两侧人行道,以及候选路口端点连接。保存写入
|
OSM 道路、原生结果、车道、转向路径、诊断和每个值的来源。可编辑道路宽度、车道数、两侧
|
||||||
|
人行道,以及候选路口端点连接。保存写入
|
||||||
`outputs/<area-id>/native-road-overrides.json`;此文件是版本化的可审查输入,下一次编译和
|
`outputs/<area-id>/native-road-overrides.json`;此文件是版本化的可审查输入,下一次编译和
|
||||||
启动工作台时会自动加载。编辑不会直接修改最终 polygon。
|
启动工作台时会自动加载。编辑不会直接修改最终 polygon。
|
||||||
|
|
||||||
道路按行驶方向显示:点击道路的任意线段后,右侧“路口连接”只列出该方向到达终点路口后
|
道路按行驶方向显示:点击道路面、中心线、车道或转向路径后,右侧“路口连接”只列出该方向到达终点路口后
|
||||||
可驶入的目标道路,并标记为左转、直行、右转或掉头;每一个目标道路只出现一次。
|
可驶入的目标道路,并标记为左转、直行、右转或掉头;每一个目标道路只出现一次。
|
||||||
|
|
||||||
诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、
|
诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、
|
||||||
|
|||||||
206
package-lock.json
generated
206
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/prompts": "^8.5.2",
|
"@inquirer/prompts": "^8.5.2",
|
||||||
|
"ol": "^10.10.0",
|
||||||
"osm2streets-js-node": "0.1.4"
|
"osm2streets-js-node": "0.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -340,6 +341,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@petamoriken/float16": {
|
||||||
|
"version": "3.9.3",
|
||||||
|
"resolved": "http://172.16.1.86:4873/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||||
|
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/rbush": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/@types/rbush/-/rbush-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@zarrita/storage": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/@zarrita/storage/-/storage-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"reference-spec-reader": "^0.2.0",
|
||||||
|
"unzipit": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chardet": {
|
"node_modules/chardet": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "http://172.16.1.86:4873/chardet/-/chardet-2.2.0.tgz",
|
"resolved": "http://172.16.1.86:4873/chardet/-/chardet-2.2.0.tgz",
|
||||||
@@ -355,6 +378,12 @@
|
|||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/earcut": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "http://172.16.1.86:4873/earcut/-/earcut-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/fast-string-truncated-width": {
|
"node_modules/fast-string-truncated-width": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "http://172.16.1.86:4873/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
|
"resolved": "http://172.16.1.86:4873/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
|
||||||
@@ -379,6 +408,31 @@
|
|||||||
"fast-string-width": "^3.0.2"
|
"fast-string-width": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "http://172.16.1.86:4873/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/geotiff": {
|
||||||
|
"version": "3.1.0-beta.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/geotiff/-/geotiff-3.1.0-beta.0.tgz",
|
||||||
|
"integrity": "sha512-wbVwOaQYuN+ywly5NoMXn37nHslwkQg5EFHqMeUTtjDACrSag0EEVlFsc+2DK3Pzmmp6XzJW2RTZOamPsBM6ng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@petamoriken/float16": "^3.9.3",
|
||||||
|
"lerc": "^3.0.0",
|
||||||
|
"pako": "^2.0.4",
|
||||||
|
"parse-headers": "^2.0.2",
|
||||||
|
"quick-lru": "^6.1.1",
|
||||||
|
"web-worker": "^1.5.0",
|
||||||
|
"xml-utils": "^1.10.2",
|
||||||
|
"zstddec": "^0.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "http://172.16.1.86:4873/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "http://172.16.1.86:4873/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
@@ -395,6 +449,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lerc": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/lerc/-/lerc-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/mute-stream": {
|
"node_modules/mute-stream": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "http://172.16.1.86:4873/mute-stream/-/mute-stream-3.0.0.tgz",
|
"resolved": "http://172.16.1.86:4873/mute-stream/-/mute-stream-3.0.0.tgz",
|
||||||
@@ -404,12 +464,121 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/numcodecs": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "http://172.16.1.86:4873/numcodecs/-/numcodecs-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ol": {
|
||||||
|
"version": "10.10.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/ol/-/ol-10.10.0.tgz",
|
||||||
|
"integrity": "sha512-tLPKn6zl+6uWdPufYlqG/lQzuVUTVmfwahQqVr5+wZNyZecyAtIhMTyOtKpu7ooNDLY2sEjKZNXw9HL+sOjC1A==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/rbush": "4.0.0",
|
||||||
|
"earcut": "^3.0.0",
|
||||||
|
"geotiff": "^3.1.0-beta.0",
|
||||||
|
"pbf": "5.1.2",
|
||||||
|
"rbush": "^4.0.0",
|
||||||
|
"zarrita": "^0.7.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/openlayers"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/osm2streets-js-node": {
|
"node_modules/osm2streets-js-node": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/osm2streets-js-node/-/osm2streets-js-node-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/osm2streets-js-node/-/osm2streets-js-node-0.1.4.tgz",
|
||||||
"integrity": "sha512-JjS6qJJjrrlEKEZxqV4s/N4qU0YfvdMUasdvfoyYCmZxEpNKAYjpuZeWJWeLEcGOqhu+YuV3YDIk9jgvMU0kKQ==",
|
"integrity": "sha512-JjS6qJJjrrlEKEZxqV4s/N4qU0YfvdMUasdvfoyYCmZxEpNKAYjpuZeWJWeLEcGOqhu+YuV3YDIk9jgvMU0kKQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/pako/-/pako-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
|
"node_modules/parse-headers": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "http://172.16.1.86:4873/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pbf": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "http://172.16.1.86:4873/pbf/-/pbf-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pbf": "bin/pbf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/protocol-buffers-schema": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "http://172.16.1.86:4873/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/quick-lru": {
|
||||||
|
"version": "6.1.2",
|
||||||
|
"resolved": "http://172.16.1.86:4873/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||||
|
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quickselect": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/rbush": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "http://172.16.1.86:4873/rbush/-/rbush-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"quickselect": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reference-spec-reader": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/resolve-protobuf-schema": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "http://172.16.1.86:4873/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "http://172.16.1.86:4873/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -427,6 +596,43 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unzipit": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/unzipit/-/unzipit-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/web-worker": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/web-worker/-/web-worker-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/xml-utils": {
|
||||||
|
"version": "1.10.2",
|
||||||
|
"resolved": "http://172.16.1.86:4873/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||||
|
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
|
"node_modules/zarrita": {
|
||||||
|
"version": "0.7.4",
|
||||||
|
"resolved": "http://172.16.1.86:4873/zarrita/-/zarrita-0.7.4.tgz",
|
||||||
|
"integrity": "sha512-NChufj86SceFj45z4CYjndmI98dphaG7hU8Wj0UGpMyT21o0V3HTY6zo87aSobYWts1tNFivSXzH2AR1AkaWjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@zarrita/storage": "^0.2.0",
|
||||||
|
"numcodecs": "^0.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zstddec": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "http://172.16.1.86:4873/zstddec/-/zstddec-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
||||||
|
"license": "MIT AND BSD-3-Clause"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/prompts": "^8.5.2",
|
"@inquirer/prompts": "^8.5.2",
|
||||||
|
"ol": "^10.10.0",
|
||||||
"osm2streets-js-node": "0.1.4"
|
"osm2streets-js-node": "0.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function handle(request, response, area, configPath) {
|
|||||||
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8");
|
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8");
|
||||||
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8");
|
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8");
|
||||||
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
|
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
|
||||||
|
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname);
|
||||||
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area));
|
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area));
|
||||||
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
|
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
|
||||||
const compiled = readCompiled(area);
|
const compiled = readCompiled(area);
|
||||||
@@ -54,5 +55,13 @@ function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|||||||
function readLayer(file) { return fs.existsSync(file) ? readJson(file) : { type: "FeatureCollection", features: [] }; }
|
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 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 sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); }
|
||||||
|
function sendVendorFile(response, pathname) {
|
||||||
|
const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname);
|
||||||
|
if (!match) return sendJson(response, 404, { error: "Not found" });
|
||||||
|
const root = path.join(repoRoot, "node_modules", match[1]);
|
||||||
|
const file = path.resolve(root, match[2]);
|
||||||
|
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return sendJson(response, 404, { error: "Not found" });
|
||||||
|
return sendFile(response, file, file.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
|
||||||
|
}
|
||||||
function sendJson(response, status, value) { response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); response.end(`${JSON.stringify(value)}\n`); }
|
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`); }
|
||||||
if (require.main === module) main();
|
if (require.main === module) main();
|
||||||
|
|||||||
@@ -1,171 +1,103 @@
|
|||||||
"use strict";
|
import Map from "/vendor/ol/Map.js";
|
||||||
|
import View from "/vendor/ol/View.js";
|
||||||
|
import VectorLayer from "/vendor/ol/layer/Vector.js";
|
||||||
|
import VectorSource from "/vendor/ol/source/Vector.js";
|
||||||
|
import GeoJSON from "/vendor/ol/format/GeoJSON.js";
|
||||||
|
import Feature from "/vendor/ol/Feature.js";
|
||||||
|
import LineString from "/vendor/ol/geom/LineString.js";
|
||||||
|
import Style from "/vendor/ol/style/Style.js";
|
||||||
|
import Fill from "/vendor/ol/style/Fill.js";
|
||||||
|
import Stroke from "/vendor/ol/style/Stroke.js";
|
||||||
|
import CircleStyle from "/vendor/ol/style/Circle.js";
|
||||||
|
import Select from "/vendor/ol/interaction/Select.js";
|
||||||
|
import { click } from "/vendor/ol/events/condition.js";
|
||||||
|
|
||||||
const canvas = document.querySelector("#map");
|
const geojson = new GeoJSON();
|
||||||
const context = canvas.getContext("2d");
|
const areaLabel = document.querySelector("#area");
|
||||||
const status = document.querySelector("#status");
|
const status = document.querySelector("#status");
|
||||||
const form = document.querySelector("#road-form");
|
const form = document.querySelector("#road-form");
|
||||||
const areaLabel = document.querySelector("#area");
|
const hint = document.querySelector("#hint");
|
||||||
|
const roadName = document.querySelector("#road-name");
|
||||||
|
const movementSummary = document.querySelector("#movement-summary");
|
||||||
|
const directionSwitch = document.querySelector("#direction-switch");
|
||||||
const widthInput = document.querySelector("#width");
|
const widthInput = document.querySelector("#width");
|
||||||
const lanesInput = document.querySelector("#lanes");
|
const lanesInput = document.querySelector("#lanes");
|
||||||
const leftInput = document.querySelector("#left");
|
const leftInput = document.querySelector("#left");
|
||||||
const rightInput = document.querySelector("#right");
|
const rightInput = document.querySelector("#right");
|
||||||
const evidence = document.querySelector("#evidence");
|
const evidence = document.querySelector("#evidence");
|
||||||
|
const diagnostics = document.querySelector("#diagnostics");
|
||||||
|
const connectionsBox = document.querySelector("#connections");
|
||||||
|
const addConnectionButton = document.querySelector("#add-connection");
|
||||||
const saveButton = document.querySelector("#save");
|
const saveButton = document.querySelector("#save");
|
||||||
const compileButton = document.querySelector("#compile");
|
const compileButton = document.querySelector("#compile");
|
||||||
const diagnostics = document.querySelector("#diagnostics");
|
|
||||||
const directionSwitch = document.querySelector("#direction-switch");
|
|
||||||
const addConnectionButton = document.querySelector("#add-connection");
|
|
||||||
const movementSummary = document.querySelector("#movement-summary");
|
|
||||||
let state;
|
let state;
|
||||||
let selected = null;
|
let selectedRoad = null;
|
||||||
let staged = [];
|
let staged = [];
|
||||||
|
const source = () => new VectorSource();
|
||||||
|
const layers = {
|
||||||
|
reference: new VectorLayer({ source: source(), style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }),
|
||||||
|
native: new VectorLayer({ source: source(), style: (feature) => feature.get("native_id")?.startsWith("junction:") ? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) }) : new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) }) }),
|
||||||
|
osm: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#263630", width: feature.get("road_id") === selectedRoad?.id ? 5 : 2 }) }) }),
|
||||||
|
lanes: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#f5f6ee", width: feature.get("road_id") === selectedRoad?.id ? 3 : 1.3, lineDash: [5, 4] }) }) }),
|
||||||
|
connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }),
|
||||||
|
diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }),
|
||||||
|
};
|
||||||
|
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.osm, layers.lanes, layers.connectors, layers.diagnostics], view: new View({ center: [0, 0], zoom: 2 }) });
|
||||||
|
const select = new Select({ condition: click, layers: [layers.osm, layers.lanes, layers.connectors, layers.native], hitTolerance: 8, style: new Style({ stroke: new Stroke({ color: "#005e89", width: 5 }), fill: new Fill({ color: "rgba(0, 94, 137, .18)" }) }) });
|
||||||
|
map.addInteraction(select);
|
||||||
|
select.on("select", ({ selected }) => { const feature = selected[0]; if (feature) selectRoad(roadForFeature(feature)); });
|
||||||
|
map.on("pointermove", (event) => { map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel, { hitTolerance: 8 }) ? "pointer" : ""; });
|
||||||
|
|
||||||
function message(text) { status.textContent = text; }
|
function message(text) { status.textContent = text; }
|
||||||
function roadLabel(road) { return road.tags.name || `${road.highway}(OSM ${road.osmWayIds.join(", ")})`; }
|
function roadLabel(road) { return road.tags.name || `${road.highway}(OSM ${road.osmWayIds.join(", ")})`; }
|
||||||
function coord(point) { const b = state.bounds; return [(point[0] - b.minX) / (b.maxX - b.minX) * canvas.width, canvas.height - (point[1] - b.minY) / (b.maxY - b.minY) * canvas.height]; }
|
function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.slice(5, laneId.lastIndexOf(":")) : ""; }
|
||||||
function setBounds() { const points = state.compiled.model.roads.flatMap((road) => road.centerline); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const pad = Math.max((Math.max(...xs) - Math.min(...xs)) * 0.06, 0.0001); state.bounds = { minX: Math.min(...xs) - pad, maxX: Math.max(...xs) + pad, minY: Math.min(...ys) - pad, maxY: Math.max(...ys) + pad }; }
|
function roadForFeature(feature) {
|
||||||
function resize() { canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; draw(); }
|
const properties = feature.getProperties();
|
||||||
function polygon(feature, fill) { const ring = feature.geometry?.coordinates?.[0]; if (!ring) return; context.beginPath(); ring.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.fillStyle = fill; context.fill(); }
|
const roadId = properties.road_id || roadIdFromLane(properties.from_lane_id) || properties.directional_road_ids?.split(",")[0];
|
||||||
function layerLine(feature, stroke, width, dashed) { const points = feature.geometry?.coordinates; if (!points?.length) return; context.save(); context.setLineDash(dashed ? [5 * devicePixelRatio, 4 * devicePixelRatio] : []); context.beginPath(); points.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.strokeStyle = stroke; context.lineWidth = width * devicePixelRatio; context.stroke(); context.restore(); }
|
return state.compiled.model.roads.find((road) => road.id === roadId) || null;
|
||||||
function draw() {
|
|
||||||
if (!state) return;
|
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
const layers = state.layers || {};
|
|
||||||
for (const feature of layers.osm2streetsRoadSurface?.features || []) polygon(feature, "#9ba8ae55");
|
|
||||||
for (const feature of layers.nativeRoadSurface?.features || []) polygon(feature, "#28695666");
|
|
||||||
for (const feature of layers.nativeIntersectionSurface?.features || []) polygon(feature, "#0e786066");
|
|
||||||
for (const feature of layers.laneCenterlines?.features || []) {
|
|
||||||
const active = feature.properties.road_id === selected?.id;
|
|
||||||
layerLine(feature, active ? "#006e91" : "#f5f6ee", active ? 2.4 : 1.2, true);
|
|
||||||
}
|
}
|
||||||
for (const feature of layers.connectors?.features || []) {
|
function readFeatures(collection) { return geojson.readFeatures(collection || { type: "FeatureCollection", features: [] }, { dataProjection: "EPSG:4326", featureProjection: "EPSG:3857" }); }
|
||||||
if (!effectiveConnectorEnabled(feature.properties)) continue;
|
function rawRoadFeatures() { return state.compiled.model.roads.map((road) => new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857"), road_id: road.id })); }
|
||||||
const active = feature.properties.from_lane_id.startsWith(`lane:${selected?.id}:`);
|
function updateSources() {
|
||||||
layerLine(feature, active ? "#d1226f" : "#ad3a76", active ? 3 : 1.8, true);
|
layers.reference.getSource().clear(); layers.reference.getSource().addFeatures(readFeatures(state.layers.osm2streetsRoadSurface));
|
||||||
|
layers.native.getSource().clear(); layers.native.getSource().addFeatures([...readFeatures(state.layers.nativeRoadSurface), ...readFeatures(state.layers.nativeIntersectionSurface)]);
|
||||||
|
layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures());
|
||||||
|
layers.lanes.getSource().clear(); layers.lanes.getSource().addFeatures(readFeatures(state.layers.laneCenterlines));
|
||||||
|
layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors));
|
||||||
|
layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) }));
|
||||||
|
const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
|
||||||
}
|
}
|
||||||
for (const road of state.compiled.model.roads) {
|
|
||||||
context.beginPath(); road.centerline.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); });
|
|
||||||
context.strokeStyle = road.id === selected?.id ? "#006e91" : "#263630";
|
|
||||||
context.lineWidth = (road.id === selected?.id ? 4 : 2) * devicePixelRatio;
|
|
||||||
context.stroke();
|
|
||||||
}
|
|
||||||
if (selected) drawDirectionArrow(selected);
|
|
||||||
for (const diagnostic of state.compiled.diagnostics) {
|
|
||||||
if (!diagnostic.geometry) continue;
|
|
||||||
const point = coord(diagnostic.geometry.coordinates); const isJunction = diagnostic.rule === "ordinary-junction-surface"; context.fillStyle = isJunction ? "#0e7860" : diagnostic.severity === "error" ? "#bf3b2e" : "#d49318"; context.beginPath(); context.arc(...point, isJunction ? 4 * devicePixelRatio : 5 * devicePixelRatio, 0, Math.PI * 2); context.fill();
|
|
||||||
}
|
|
||||||
if (state.focusedDiagnostic?.geometry) { const point = coord(state.focusedDiagnostic.geometry.coordinates); context.strokeStyle = "#006e91"; context.lineWidth = 3 * devicePixelRatio; context.beginPath(); context.arc(...point, 11 * devicePixelRatio, 0, Math.PI * 2); context.stroke(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawDirectionArrow(road) {
|
|
||||||
const middle = Math.max(1, Math.floor(road.centerline.length / 2));
|
|
||||||
const a = coord(road.centerline[middle - 1]); const b = coord(road.centerline[middle]);
|
|
||||||
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); const size = 10 * devicePixelRatio;
|
|
||||||
context.save(); context.translate(b[0], b[1]); context.rotate(angle); context.fillStyle = "#006e91";
|
|
||||||
context.beginPath(); context.moveTo(size, 0); context.lineTo(-size * 0.8, -size * 0.6); context.lineTo(-size * 0.8, size * 0.6); context.closePath(); context.fill(); context.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function select(road) {
|
|
||||||
selected = road; form.hidden = !road; document.querySelector("#hint").hidden = Boolean(road);
|
|
||||||
if (!road) return;
|
|
||||||
document.querySelector("#road-name").textContent = `${roadLabel(road)}(${road.direction === "forward" ? "沿 OSM 方向行驶" : "逆 OSM 方向行驶"})`;
|
|
||||||
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
|
|
||||||
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 名称: road.tags.name || "未标注", 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
|
|
||||||
renderMovementSummary(road);
|
|
||||||
renderDirectionSwitch(road);
|
|
||||||
renderConnections(road); draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMovementSummary(road) {
|
|
||||||
const connectors = (state.layers.connectors?.features || []).filter((feature) => feature.properties.from_lane_id.startsWith(`lane:${road.id}:`) && effectiveConnectorEnabled(feature.properties));
|
|
||||||
const byTurn = connectors.reduce((result, feature) => { result[feature.properties.turn] = (result[feature.properties.turn] || 0) + 1; return result; }, {});
|
|
||||||
const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" };
|
|
||||||
movementSummary.textContent = connectors.length ? `当前方向已生成 ${connectors.length} 条路径:${Object.entries(byTurn).map(([turn, count]) => `${labels[turn] || turn} ${count}`).join(",")}` : "当前方向没有已生成的转向路径";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDirectionSwitch(road) {
|
|
||||||
directionSwitch.innerHTML = "";
|
|
||||||
const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
|
|
||||||
if (alternatives.length < 2) { directionSwitch.textContent = "单向道路:沿 OSM 节点顺序行驶"; return; }
|
|
||||||
const note = document.createElement("label"); note.textContent = "编辑方向(地图蓝色箭头表示当前方向)"; directionSwitch.append(note);
|
|
||||||
for (const item of alternatives) {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button"; button.textContent = item.direction === "forward" ? "沿 OSM 节点顺序" : "逆 OSM 节点顺序";
|
|
||||||
button.disabled = item.id === road.id; button.onclick = () => select(item); directionSwitch.append(button);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderConnections(road) {
|
|
||||||
const box = document.querySelector("#connections"); box.innerHTML = "";
|
|
||||||
const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end");
|
|
||||||
addConnectionButton.hidden = !endpoint;
|
|
||||||
addConnectionButton.onclick = () => renderAdditionalConnections(road, endpoint);
|
|
||||||
const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
|
|
||||||
if (!rows.length) { box.textContent = "当前行驶方向到达道路终点后,没有可编辑的驶出道路。"; return; }
|
|
||||||
const intro = document.createElement("p"); intro.textContent = "到达终点路口后,允许驶入:"; box.append(intro);
|
|
||||||
const seen = new Set();
|
|
||||||
for (const connection of rows) {
|
|
||||||
const targetEndpoint = state.compiled.model.endpoints.find((item) => item.id === connection.toEndpointId);
|
|
||||||
const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint?.roadId);
|
|
||||||
if (!target || seen.has(target.id)) continue;
|
|
||||||
seen.add(target.id);
|
|
||||||
const input = document.createElement("input"); const label = document.createElement("label");
|
|
||||||
input.type = "checkbox"; input.checked = effectiveConnectionEnabled(connection); input.onchange = () => { stageConnection(connection, input.checked); renderConnections(road); draw(); };
|
|
||||||
label.append(input, ` ${turnName(road, target)}:${roadLabel(target)}`); box.append(label);
|
|
||||||
renderLaneConnectionControls(box, connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLaneConnectionControls(box, connection) {
|
|
||||||
const connectors = state.layers.connectors?.features.filter((feature) => feature.properties.connection_id === connection.id) || [];
|
|
||||||
const rows = connectors.map((feature) => ({ from_lane_id: feature.properties.from_lane_id, to_lane_id: feature.properties.to_lane_id, enabled: true }));
|
|
||||||
const sourceRoad = connection.fromEndpointId.slice("endpoint:".length).replace(/:end$/, "");
|
|
||||||
const targetRoad = connection.toEndpointId.slice("endpoint:".length).replace(/:start$/, "");
|
|
||||||
for (const override of state.overrides.overrides.filter((item) => item.kind === "lane-connection" && item.fromLaneId.startsWith(`lane:${sourceRoad}:`) && item.toLaneId.startsWith(`lane:${targetRoad}:`))) {
|
|
||||||
if (!rows.some((row) => row.from_lane_id === override.fromLaneId && row.to_lane_id === override.toLaneId)) rows.push({ from_lane_id: override.fromLaneId, to_lane_id: override.toLaneId, enabled: override.enabled });
|
|
||||||
}
|
|
||||||
for (const row of rows) appendLaneConnectionControl(box, row);
|
|
||||||
}
|
|
||||||
function appendLaneConnectionControl(box, row) { const fromIndex = row.from_lane_id.split(":").at(-1); const toIndex = row.to_lane_id.split(":").at(-1); const input = document.createElement("input"); const label = document.createElement("label"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); if (selected) { renderConnections(selected); renderMovementSummary(selected); draw(); } }; label.append(input, ` 第 ${fromIndex} 车道 → 第 ${toIndex} 车道`); box.append(label); }
|
|
||||||
|
|
||||||
function renderAdditionalConnections(road, endpoint) {
|
|
||||||
const box = document.querySelector("#connections");
|
|
||||||
const existing = new Set(state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint.id).map((connection) => connection.toEndpointId));
|
|
||||||
const candidates = state.compiled.model.endpoints.filter((item) => item.side === "start" && item.roadId !== road.id && !existing.has(item.id) && endpointDistanceMeters(endpoint, item) <= 35);
|
|
||||||
box.innerHTML = "";
|
|
||||||
if (!candidates.length) { box.textContent = "附近 35 米内没有可新增的驶出道路。请检查 OSM 路口节点,或选择另一条道路。"; return; }
|
|
||||||
const intro = document.createElement("p"); intro.textContent = "选择要新增允许驶入的道路:"; box.append(intro);
|
|
||||||
for (const targetEndpoint of candidates) {
|
|
||||||
const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint.roadId);
|
|
||||||
const button = document.createElement("button"); button.type = "button"; button.textContent = `${turnName(road, target)}:${roadLabel(target)}(相距 ${endpointDistanceMeters(endpoint, targetEndpoint).toFixed(1)} 米)`;
|
|
||||||
button.onclick = () => { stageConnection({ id: `manual:${endpoint.id}:${targetEndpoint.id}`, fromEndpointId: endpoint.id, toEndpointId: targetEndpoint.id }, true); renderConnections(road); };
|
|
||||||
box.append(button);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function turnName(from, to) {
|
|
||||||
const a = heading(from.centerline.at(-2), from.centerline.at(-1));
|
|
||||||
const b = heading(to.centerline[0], to.centerline[1]);
|
|
||||||
const delta = ((b - a + 540) % 360) - 180;
|
|
||||||
if (Math.abs(delta) >= 150) return "掉头";
|
|
||||||
if (Math.abs(delta) <= 30) return "直行";
|
|
||||||
return delta > 0 ? "右转" : "左转";
|
|
||||||
}
|
|
||||||
function heading(a, b) { return Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; }
|
|
||||||
function endpointDistanceMeters(a, b) { const dx = (a.coordinate[0] - b.coordinate[0]) * 111320 * Math.cos(a.coordinate[1] * Math.PI / 180); const dy = (a.coordinate[1] - b.coordinate[1]) * 111320; return Math.hypot(dx, dy); }
|
|
||||||
function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); message("有未保存修改"); }
|
|
||||||
function stageLaneConnection(connector, enabled) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId: connector.from_lane_id, toLaneId: connector.to_lane_id, enabled }); message("有未保存修改"); }
|
|
||||||
function effectiveLaneEnabled(connector) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; }
|
function effectiveLaneEnabled(connector) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; }
|
||||||
function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; }
|
function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; }
|
||||||
function effectiveConnectorEnabled(connector) { const connection = state.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); }
|
function effectiveConnectorEnabled(connector) { const connection = state?.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); }
|
||||||
function pointToSegmentDistance(point, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lengthSquared = dx * dx + dy * dy; const t = lengthSquared ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSquared)) : 0; return Math.hypot(point[0] - (a[0] + t * dx), point[1] - (a[1] + t * dy)); }
|
|
||||||
function pickRoad(point) { let best = null; let distance = Infinity; for (const road of state.compiled.model.roads) for (let index = 1; index < road.centerline.length; index += 1) { const candidate = pointToSegmentDistance(point, coord(road.centerline[index - 1]), coord(road.centerline[index])); if (candidate < distance) { distance = candidate; best = road; } } return distance <= 18 * devicePixelRatio ? best : null; }
|
function selectRoad(road) {
|
||||||
canvas.onclick = (event) => { const rect = canvas.getBoundingClientRect(); select(pickRoad([(event.clientX - rect.left) * devicePixelRatio, (event.clientY - rect.top) * devicePixelRatio])); };
|
selectedRoad = road; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed();
|
||||||
form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selected.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selected.id, widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }); message("有未保存修改"); };
|
form.hidden = !road; hint.hidden = Boolean(road); if (!road) return;
|
||||||
saveButton.onclick = async () => { const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const data = await response.json(); if (!data.ok) return message(data.error); state.overrides = data.overrides; staged = []; message("已保存,点击“保存并重新生成”生效"); };
|
roadName.textContent = `${roadLabel(road)}(${road.direction === "forward" ? "沿 OSM 节点顺序" : "逆 OSM 节点顺序"})`;
|
||||||
compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; setup(); message("已按保存的修改重新生成"); };
|
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
|
||||||
function focusDiagnostic(diagnostic) { state.focusedDiagnostic = diagnostic; const road = state.compiled.model.roads.find((item) => item.id === diagnostic.subjectId); if (road) { select(road); message(diagnostic.message); } else { draw(); message(diagnostic.message); } }
|
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
|
||||||
function showInIssueList(diagnostic) { return diagnostic.severity === "error" || diagnostic.rule !== "ordinary-junction-surface"; }
|
renderDirectionSwitch(road); renderMovementSummary(road); renderConnections(road); message(`已选中:${roadLabel(road)}`);
|
||||||
function setup() { areaLabel.textContent = state.areaId; diagnostics.innerHTML = ""; const issues = state.compiled.diagnostics.filter(showInIssueList); if (!issues.length) diagnostics.innerHTML = "<li>没有需要人工检查的问题。</li>"; for (const diagnostic of issues) { const item = document.createElement("li"); const button = document.createElement("button"); button.className = diagnostic.severity === "error" ? "error" : ""; button.textContent = diagnostic.message; button.onclick = () => focusDiagnostic(diagnostic); item.append(button); diagnostics.append(item); } setBounds(); resize(); select(null); }
|
}
|
||||||
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; setup(); message(`${state.compiled.model.roads.length} 条方向道路,${state.compiled.diagnostics.length} 个待检查项`); }).catch((error) => message(error.message));
|
function renderDirectionSwitch(road) {
|
||||||
window.onresize = resize;
|
directionSwitch.innerHTML = ""; const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
|
||||||
|
if (alternatives.length < 2) { directionSwitch.textContent = "单向道路"; return; }
|
||||||
|
for (const item of alternatives) { const button = document.createElement("button"); button.type = "button"; button.textContent = item.direction === "forward" ? "沿 OSM 节点顺序" : "逆 OSM 节点顺序"; button.disabled = item.id === road.id; button.onclick = () => selectRoad(item); directionSwitch.append(button); }
|
||||||
|
}
|
||||||
|
function renderMovementSummary(road) { const connectors = state.layers.connectors?.features.filter((feature) => roadIdFromLane(feature.properties.from_lane_id) === road.id && effectiveConnectorEnabled(feature.properties)) || []; const turns = connectors.reduce((result, item) => { result[item.properties.turn] = (result[item.properties.turn] || 0) + 1; return result; }, {}); const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; movementSummary.textContent = connectors.length ? `已生成 ${connectors.length} 条路径:${Object.entries(turns).map(([key, value]) => `${labels[key] || key} ${value}`).join(",")}` : "当前方向没有已生成的转向路径"; }
|
||||||
|
function renderConnections(road) {
|
||||||
|
connectionsBox.innerHTML = ""; const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end"); addConnectionButton.hidden = !endpoint; const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
|
||||||
|
if (!rows.length) { connectionsBox.textContent = "当前方向到达终点后没有候选驶出道路。"; return; }
|
||||||
|
for (const connection of rows) { const target = state.compiled.model.roads.find((item) => item.id === state.compiled.model.endpoints.find((endpointItem) => endpointItem.id === connection.toEndpointId)?.roadId); if (!target) continue; const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveConnectionEnabled(connection); input.onchange = () => { stageConnection(connection, input.checked); selectRoad(road); }; label.append(input, ` ${turnName(road, target)}:${roadLabel(target)}`); connectionsBox.append(label); renderLaneControls(connection); }
|
||||||
|
}
|
||||||
|
function renderLaneControls(connection) { const rows = state.layers.connectors?.features.filter((feature) => feature.properties.connection_id === connection.id).map((feature) => feature.properties) || []; for (const row of rows) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); selectRoad(selectedRoad); }; label.append(input, ` 第 ${row.from_lane_id.split(":").at(-1)} 车道 → 第 ${row.to_lane_id.split(":").at(-1)} 车道`); connectionsBox.append(label); } }
|
||||||
|
function turnName(from, to) { const heading = (a, b) => Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; const delta = ((heading(to.centerline[0], to.centerline[1]) - heading(from.centerline.at(-2), from.centerline.at(-1)) + 540) % 360) - 180; return Math.abs(delta) >= 150 ? "掉头" : Math.abs(delta) <= 30 ? "直行" : delta > 0 ? "右转" : "左转"; }
|
||||||
|
function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); message("有未保存修改"); }
|
||||||
|
function stageLaneConnection(connector, enabled) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId: connector.from_lane_id, toLaneId: connector.to_lane_id, enabled }); message("有未保存修改"); }
|
||||||
|
function renderDiagnostics() { diagnostics.innerHTML = ""; for (const item of state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface")) { const button = document.createElement("button"); button.textContent = item.message; button.onclick = () => selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId)); diagnostics.append(button); } }
|
||||||
|
form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selectedRoad.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selectedRoad.id, widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }); message("有未保存修改"); };
|
||||||
|
saveButton.onclick = async () => { const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) return message(result.error); state.overrides = result.overrides; staged = []; message("已保存,点击“保存并重新生成”写入几何"); };
|
||||||
|
compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateSources(); renderDiagnostics(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已重新生成"); };
|
||||||
|
for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { layers[input.dataset.layer].setVisible(input.checked); };
|
||||||
|
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateSources(); renderDiagnostics(); areaLabel.textContent = state.areaId; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/app.css"></head>
|
<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><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
|
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
|
||||||
<main><aside class="issues"><h1>待检查问题</h1><p class="muted">点击问题可定位到道路或路口。</p><ul id="diagnostics"></ul></aside><section class="map"><canvas id="map"></canvas><div class="legend"><i class="reference"></i> osm2streets 参考面 <i class="native"></i> 自研道路面 <i class="lane"></i> 车道中心线 <i class="connector"></i> 转向路径 <i class="junction"></i> 已识别路口 <i class="warning"></i> 待检查点</div></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">在地图中点击道路,查看和调整参数。</p><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.1"></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><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 src="/app.js"></script></body></html>
|
<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="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>待检查问题</h1><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道或转向路径以查看详情。</p><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.1"></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><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>
|
||||||
|
|||||||
Reference in New Issue
Block a user