feat: migrate workbench to React
This commit is contained in:
@@ -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