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' } },
|
||||
});
|
||||
Reference in New Issue
Block a user