feat: migrate workbench to React

This commit is contained in:
2026-08-26 15:01:07 +08:00
parent f15e69c868
commit 3ddb33e321
48 changed files with 16748 additions and 1509 deletions

View File

@@ -1 +1,223 @@
*{box-sizing:border-box}body{margin:0;background:#eef1ef;color:#202523;font:14px system-ui,sans-serif}header{height:50px;display:flex;gap:12px;align-items:center;padding:0 16px;background:#183a32;color:#fff}header span{color:#c9d8d2}#dirty-state.dirty{color:#ffe08a;font-weight:700}button{border:1px solid #82988f;background:#fff;color:#1d392f;padding:7px 10px;border-radius:3px;cursor:pointer}button:disabled{cursor:default;opacity:.55}header button:first-of-type{margin-left:auto}main{display:grid;grid-template-columns:260px minmax(0,1fr) 320px;height:calc(100vh - 50px)}aside{overflow:auto;background:#fff;padding:16px}.issues{border-right:1px solid #d5dfda}.inspector{border-left:1px solid #d5dfda}.map{position:relative;background:#d7e2de;min-height:400px}canvas{width:100%;height:100%;display:block}.legend{position:absolute;bottom:12px;left:12px;background:#fff;padding:8px;box-shadow:0 1px 4px #0003}.legend i{display:inline-block;width:18px;height:7px;margin:0 4px -1px 10px}.reference{background:#a5b0b5}.native{background:#296654}.line{height:3px!important;background:#263630}.junction{width:10px!important;height:10px!important;background:#0e7860;border-radius:50%}.warning{width:10px!important;height:10px!important;background:#d49318;border-radius:50%}h1{font-size:16px;margin:0 0 8px}h2{font-size:14px;margin:12px 0 8px}.muted,output,pre{color:#52615b}.issues ul{list-style:none;padding:0;margin:0}.issues button{width:100%;text-align:left;margin:4px 0;background:#fff7e5;border-color:#e7c67b;color:#693c00}.issues button.error{background:#fff0ee;border-color:#e3a49b;color:#8d261a}.segmented{display:flex;margin:0 0 8px}.segmented button{flex:1;border-radius:0;padding:6px 4px;font-size:12px}.segmented button+button{border-left:0}.segmented button:first-child{border-radius:3px 0 0 3px}.segmented button:last-child{border-radius:0 3px 3px 0}.segmented button.active{background:#286956;border-color:#286956;color:#fff}label{display:block;margin:10px 0}input[type=number]{display:block;width:100%;padding:7px;border:1px solid #aab8b2;border-radius:2px}output,pre{display:block;white-space:pre-wrap;overflow-wrap:anywhere}form button{margin-top:8px;background:#286956;color:white;border:0}dl{display:grid;grid-template-columns:1fr auto;gap:5px 10px;margin:0}dt{color:#52615b}dd{margin:0;font-variant-numeric:tabular-nums}hr{border:0;border-top:1px solid #dde4e1;margin:16px 0}details{margin-top:16px}summary{cursor:pointer;font-weight:600}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr)}.issues{display:none}.inspector{position:absolute;right:0;bottom:0;width:min(360px,100%);max-height:55vh;border-top:1px solid #d5dfda}}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #eef1ef;
color: #202523;
font:
14px system-ui,
sans-serif;
}
header {
height: 50px;
display: flex;
gap: 12px;
align-items: center;
padding: 0 16px;
background: #183a32;
color: #fff;
}
header span {
color: #c9d8d2;
}
#dirty-state.dirty {
color: #ffe08a;
font-weight: 700;
}
button {
border: 1px solid #82988f;
background: #fff;
color: #1d392f;
padding: 7px 10px;
border-radius: 3px;
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.55;
}
header button:first-of-type {
margin-left: auto;
}
main {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 320px;
height: calc(100vh - 50px);
}
aside {
overflow: auto;
background: #fff;
padding: 16px;
}
.issues {
border-right: 1px solid #d5dfda;
}
.inspector {
border-left: 1px solid #d5dfda;
}
.map {
position: relative;
background: #d7e2de;
min-height: 400px;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
.legend {
position: absolute;
bottom: 12px;
left: 12px;
background: #fff;
padding: 8px;
box-shadow: 0 1px 4px #0003;
}
.legend i {
display: inline-block;
width: 18px;
height: 7px;
margin: 0 4px -1px 10px;
}
.reference {
background: #a5b0b5;
}
.native {
background: #296654;
}
.line {
height: 3px !important;
background: #263630;
}
.junction {
width: 10px !important;
height: 10px !important;
background: #0e7860;
border-radius: 50%;
}
.warning {
width: 10px !important;
height: 10px !important;
background: #d49318;
border-radius: 50%;
}
h1 {
font-size: 16px;
margin: 0 0 8px;
}
h2 {
font-size: 14px;
margin: 12px 0 8px;
}
.muted,
output,
pre {
color: #52615b;
}
.issues ul {
list-style: none;
padding: 0;
margin: 0;
}
.issues button {
width: 100%;
text-align: left;
margin: 4px 0;
background: #fff7e5;
border-color: #e7c67b;
color: #693c00;
}
.issues button.error {
background: #fff0ee;
border-color: #e3a49b;
color: #8d261a;
}
.segmented {
display: flex;
margin: 0 0 8px;
}
.segmented button {
flex: 1;
border-radius: 0;
padding: 6px 4px;
font-size: 12px;
}
.segmented button + button {
border-left: 0;
}
.segmented button:first-child {
border-radius: 3px 0 0 3px;
}
.segmented button:last-child {
border-radius: 0 3px 3px 0;
}
.segmented button.active {
background: #286956;
border-color: #286956;
color: #fff;
}
label {
display: block;
margin: 10px 0;
}
input[type='number'] {
display: block;
width: 100%;
padding: 7px;
border: 1px solid #aab8b2;
border-radius: 2px;
}
output,
pre {
display: block;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
form button {
margin-top: 8px;
background: #286956;
color: white;
border: 0;
}
dl {
display: grid;
grid-template-columns: 1fr auto;
gap: 5px 10px;
margin: 0;
}
dt {
color: #52615b;
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
hr {
border: 0;
border-top: 1px solid #dde4e1;
margin: 16px 0;
}
details {
margin-top: 16px;
}
summary {
cursor: pointer;
font-weight: 600;
}
@media (max-width: 900px) {
main {
grid-template-columns: minmax(0, 1fr);
}
.issues {
display: none;
}
.inspector {
position: absolute;
right: 0;
bottom: 0;
width: min(360px, 100%);
max-height: 55vh;
border-top: 1px solid #d5dfda;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,12 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/vendor/ol/ol.css"><link rel="stylesheet" href="/app.css"></head>
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><span id="dirty-state" aria-live="polite"></span><label style="display:inline;margin:0 0 0 auto;white-space:nowrap"><input id="scene-preview" type="checkbox"> 场景效果</label><a id="export-package" href="/api/export.zip" download>导出道路包</a><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
<main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="sidewalks" type="checkbox" checked> 路缘与步行带</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="gaodeReference" type="checkbox" checked> 高德规整路口参考</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道、转向路径或路口面以查看详情。</p><section id="selected-junction" hidden><h2>当前路口</h2><output id="junction-detail"></output></section><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.01"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有路缘与步行带</label><label><input id="right" type="checkbox"> 右侧有路缘与步行带</label><button type="submit">暂存本道路修改</button></form><form id="center-line-form" hidden><h2 id="marking-style-heading">道路中心线样式</h2><output id="center-line-segment"></output><label>样式<select id="center-line-style"><option value="yellow-dashed">黄色虚线(默认)</option><option value="white-dashed">白色虚线</option><option value="yellow-solid">黄色实线</option><option value="white-solid">白色实线</option></select></label><button type="submit">暂存标线样式</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence"></pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>道路编译工作台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,638 @@
import { useEffect, useState } from 'react';
import { Download, RefreshCw, Save, Upload } from 'lucide-react';
import { api } from './lib/api';
import { MapCanvas } from './components/MapCanvas';
import { Button } from './ui/button';
import type { GeoFeature, Override, Road, WorkbenchState } from './types/state';
import type { LayerName } from './map/layers';
const layerLabels: Array<[LayerName, string]> = [
['osm', 'OSM 道路中心线'],
['native', '自研道路与路口面'],
['sidewalks', '路缘与步行带'],
['lanes', '车道与转向路径'],
['directionArrows', '道路方向箭头'],
['markings', '车道分隔线与路口转向箭头'],
['centerLines', '道路中心线'],
['edgeLines', '道路外缘线'],
['controls', '斑马线与停止线'],
['signals', '红绿灯设施'],
['gaodeReference', '高德规整路口参考'],
['reference', 'osm2streets 参考面'],
];
const defaultLayers = Object.fromEntries(
layerLabels.map(([key]) => [key, key !== 'edgeLines' && key !== 'reference']),
) as Record<LayerName, boolean>;
const direction = (road: Road) => (road.direction === 'forward' ? '沿 OSM 方向' : '逆 OSM 方向');
const label = (road: Road) => road.tags.name || `${road.highway}OSM ${road.osmWayIds.join(', ')}`;
const laneIndex = (id: string) => Number(id.split(':').at(-1));
function App() {
const [state, setState] = useState<WorkbenchState>();
const [selected, setSelected] = useState<Road | null>(null);
const [staged, setStaged] = useState<Override[]>([]);
const [layers, setLayers] = useState(defaultLayers);
const [scene, setScene] = useState(false);
const [status, setStatus] = useState('正在加载工作台...');
const [filter, setFilter] = useState<'all' | 'candidates' | 'other'>('all');
const [marking, setMarking] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
api
.state()
.then((value) => {
if ('compiled' in value) {
setState(value);
setStatus(`已加载 ${value.compiled.model.roads.length} 条方向道路`);
} else setStatus('请导入 OSM 文件以开始');
})
.catch((error: Error) => setStatus(error.message));
}, []);
const stage = (change: Override) =>
setStaged((current) => [...current.filter((item) => item.id !== change.id), change]);
const save = async () => {
if (!state || !staged.length) return true;
try {
const overrides = [
...state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)),
...staged,
];
const result = await api.overrides(overrides);
setState({ ...state, overrides: result.overrides });
setStaged([]);
setStatus('已保存,点击“保存并重新生成”写入几何');
return true;
} catch (error) {
setStatus((error as Error).message);
return false;
}
};
const compile = async () => {
if (!(await save())) return;
try {
setStatus('正在重新生成...');
const next = await api.compile();
setState(next);
setSelected((current) => next.compiled.model.roads.find((road) => road.id === current?.id) || null);
setStatus('已保存并重新生成');
} catch (error) {
setStatus((error as Error).message);
}
};
if (!state)
return (
<ImportScreen
status={status}
onImported={(next) => {
setState(next);
setStatus(`已导入 ${next.areaId}`);
}}
/>
);
const diagnostics = state.compiled.diagnostics.filter(
(item) =>
item.rule !== 'ordinary-junction-surface' &&
(filter === 'all' || filter === 'candidates'
? Boolean(item.manualCandidates?.length)
: !item.manualCandidates?.length),
);
const handleFeature = (properties: Record<string, unknown>) => {
setMarking(properties);
const uid = properties.signal_uid;
if (typeof uid === 'string') setStatus(`已选中原生红绿灯 ${uid}`);
else if (properties.effective_style) setStatus('已选中标线,可在右侧暂存样式');
};
return (
<>
<header>
<strong></strong>
<span>{state.areaId}</span>
<span>{status}</span>
<span className={staged.length ? 'dirty' : ''}>
{staged.length ? `未保存修改 ${staged.length}` : '所有修改已保存'}
</span>
<label className="scene">
<input type="checkbox" checked={scene} onChange={(event) => setScene(event.target.checked)} />
</label>
<a className="button" href="/api/export.zip" download>
<Download size={15} />
</a>
<Button onClick={() => void save()} disabled={!staged.length}>
<Save size={15} />
</Button>
<Button onClick={() => void compile()}>
<RefreshCw size={15} />
</Button>
</header>
<main>
<aside className="issues">
<h1></h1>
{layerLabels.map(([key, text]) => (
<label key={key}>
<input
type="checkbox"
checked={layers[key]}
onChange={(event) => setLayers({ ...layers, [key]: event.target.checked })}
/>{' '}
{text}
</label>
))}
<hr />
<h1></h1>
<dl>
{Object.entries(state.comparison).map(([key, value]) => (
<>
<dt key={`${key}-t`}>{key}</dt>
<dd key={`${key}-d`}>{String(value)}</dd>
</>
))}
</dl>
<hr />
<h1></h1>
<div className="segmented">
{(['all', 'candidates', 'other'] as const).map((kind) => (
<Button className={filter === kind ? 'active' : ''} onClick={() => setFilter(kind)} key={kind}>
{kind === 'all' ? '全部' : kind === 'candidates' ? '可连接' : '其他'}
</Button>
))}
</div>
<ul>
{diagnostics.map((item) => (
<li key={item.id}>
<Button
onClick={() => {
const road = state.compiled.model.roads.find((itemRoad) => itemRoad.id === item.subjectId);
if (road) setSelected(road);
setStatus(item.message);
}}
>
{item.message}
</Button>
</li>
))}
</ul>
</aside>
<MapCanvas
state={state}
selected={selected}
visible={layers}
scene={scene}
onSelectRoad={setSelected}
onFeature={handleFeature}
/>
<Inspector
road={selected}
state={state}
staged={staged}
marking={marking}
stage={stage}
status={setStatus}
setState={setState}
/>
</main>
</>
);
}
function ImportScreen({ status, onImported }: { status: string; onImported: (state: WorkbenchState) => void }) {
const [file, setFile] = useState<File>();
const [busy, setBusy] = useState(false);
return (
<section className="import-screen">
<form
onSubmit={async (event) => {
event.preventDefault();
if (!file) return;
setBusy(true);
try {
onImported(await api.import(file));
} catch (error) {
console.error(error);
} finally {
setBusy(false);
}
}}
>
<h1> OSM </h1>
<p>{status}</p>
<input
type="file"
accept=".osm,application/xml,text/xml"
onChange={(event) => setFile(event.target.files?.[0])}
required
/>
<Button type="submit" disabled={!file || busy}>
<Upload size={16} /> {busy ? '正在导入并编译...' : '导入并编译'}
</Button>
</form>
</section>
);
}
function Inspector({
road,
state,
staged,
marking,
stage,
status,
setState,
}: {
road: Road | null;
state: WorkbenchState;
staged: Override[];
marking: Record<string, unknown> | null;
stage: (item: Override) => void;
status: (value: string) => void;
setState: (next: WorkbenchState) => void;
}) {
const [width, setWidth] = useState(0);
const [lanes, setLanes] = useState(1);
const [left, setLeft] = useState(false);
const [right, setRight] = useState(false);
useEffect(() => {
if (road) {
setWidth(road.widthMeters);
setLanes(road.laneCount);
setLeft(road.sidewalkLeft);
setRight(road.sidewalkRight);
}
}, [road]);
const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road?.id && item.side === 'end');
const movements = state.compiled.movements?.filter((item) => item.fromRoadId === road?.id) || [];
const effective = (id: string, fallback: boolean) =>
((staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id))?.enabled as
boolean | undefined) ?? fallback;
const updateSignal = async (features: GeoFeature[]) => {
try {
const result = await api.signals({
...state.trafficSignals,
assemblies: { ...state.trafficSignals.assemblies, features },
});
setState({ ...state, trafficSignals: result.trafficSignals, trafficRuntime: result.trafficRuntime });
status('红绿灯已保存');
} catch (error) {
status((error as Error).message);
}
};
const signalUid = typeof marking?.signal_uid === 'string' ? marking.signal_uid : '';
const signal = state.trafficSignals.assemblies.features.find((item) => item.properties?.signal_uid === signalUid);
return (
<aside className="inspector">
<h1></h1>
{road ? (
<form
onSubmit={(event) => {
event.preventDefault();
stage({
id: `道路:${road.id}`,
kind: 'road',
roadId: road.id,
widthMeters: width,
laneCount: lanes,
sidewalkLeft: left,
sidewalkRight: right,
});
const opposite = state.compiled.model.roads.find(
(item) => item.id !== road.id && item.segmentId === road.segmentId,
);
if (opposite)
stage({
id: `道路:${opposite.id}`,
kind: 'road',
roadId: opposite.id,
sidewalkLeft: right,
sidewalkRight: left,
});
status('有未保存修改');
}}
>
<label></label>
<output>
{label(road)}{direction(road)}
</output>
<output>
{movements.length
? `已识别 ${movements.length} 个行驶动作,${movements.filter((item) => item.geometryPublished).length} 条已绘制路径`
: '当前方向没有已识别的行驶动作'}
</output>
<label>
<input
type="number"
min="1"
step="0.01"
value={width}
onChange={(event) => setWidth(Number(event.target.value))}
/>
</label>
<label>
<input
type="number"
min="1"
step="1"
value={lanes}
onChange={(event) => setLanes(Number(event.target.value))}
/>
</label>
<label>
<input type="checkbox" checked={left} onChange={(event) => setLeft(event.target.checked)} />{' '}
</label>
<label>
<input type="checkbox" checked={right} onChange={(event) => setRight(event.target.checked)} />{' '}
</label>
<Button type="submit"></Button>
</form>
) : (
<p>线</p>
)}
<hr />
<h2></h2>
{road &&
state.compiled.model.connections
.filter((connection) => connection.fromEndpointId === endpoint?.id)
.map((connection) => (
<label key={connection.id}>
<input
type="checkbox"
checked={effective(`连接:${connection.id}`, connection.enabled)}
onChange={(event) =>
stage({
id: `连接:${connection.id}`,
kind: 'junction-connection',
fromEndpointId: connection.fromEndpointId,
toEndpointId: connection.toEndpointId,
enabled: event.target.checked,
})
}
/>{' '}
{connection.id}
</label>
))}
{movements.map((movement) => (
<label key={movement.id}>
<input
type="checkbox"
checked={effective(`车道连接:${movement.fromLaneId}->${movement.toLaneId}`, true)}
onChange={(event) =>
stage({
id: `车道连接:${movement.fromLaneId}->${movement.toLaneId}`,
kind: 'lane-connection',
fromLaneId: movement.fromLaneId,
toLaneId: movement.toLaneId,
enabled: event.target.checked,
})
}
/>{' '}
{movement.turn} {laneIndex(movement.fromLaneId)} {laneIndex(movement.toLaneId)}
{movement.geometryStatus || 'connector'}
</label>
))}
{endpoint &&
state.compiled.diagnostics
.find((item) => item.endpointId === endpoint.id)
?.manualCandidates?.map((candidate) => {
const target = state.compiled.model.roads.find((item) => item.id === candidate.roadId);
const targetEndpoint = state.compiled.model.endpoints.find(
(item) => item.roadId === candidate.roadId && item.side === 'start',
);
return target && targetEndpoint ? (
<Button
key={candidate.roadId}
onClick={() => {
stage({
id: `连接:connection:${endpoint.id}:${targetEndpoint.id}`,
kind: 'junction-connection',
fromEndpointId: endpoint.id,
toEndpointId: targetEndpoint.id,
enabled: true,
});
status('手工连接已暂存;保存并重新生成后会出现转向路径');
}}
>
{label(target)}{candidate.distanceMeters}
</Button>
) : null;
})}
<MarkingEditor marking={marking} stage={stage} />
<SignalEditor state={state} signal={signal} status={status} update={updateSignal} />
<Candidates state={state} setState={setState} status={status} />
<details>
<summary></summary>
<pre>
{JSON.stringify(
road ? { OSM道路: road.osmWayIds, 当前方向节点顺序: road.sourceNodeIds, 原始标签: road.tags } : marking,
null,
2,
)}
</pre>
</details>
</aside>
);
}
function MarkingEditor({
marking,
stage,
}: {
marking: Record<string, unknown> | null;
stage: (item: Override) => void;
}) {
const style = typeof marking?.effective_style === 'string' ? marking.effective_style : '';
const [value, setValue] = useState(style || 'yellow-dashed');
useEffect(() => setValue(style || 'yellow-dashed'), [style]);
if (!style || !marking) return null;
const roadId = String(marking.road_id);
const segment = marking.segment_id;
const kind = String(marking.provenance || '');
const save = () => {
const [color, pattern] = value.replace('double-', '').split('-');
if (segment)
stage({
id: `道路中心线:${segment}`,
kind: 'center-line-style',
segmentId: segment,
color,
pattern,
double: value.startsWith('double-'),
});
else if (kind.includes('edge-line'))
stage({
id: `道路外缘线:${roadId}:${marking.side}`,
kind: 'edge-line-style',
roadId,
side: marking.side,
color,
pattern,
});
else
stage({
id: `车道分隔线:${roadId}:${marking.left_lane_index}-${marking.right_lane_index}`,
kind: 'lane-separator-style',
roadId,
leftLaneIndex: marking.left_lane_index,
rightLaneIndex: marking.right_lane_index,
color,
pattern,
});
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
save();
}}
>
<hr />
<h2>线</h2>
<label>
<select
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
>
<option value="yellow-dashed">线</option>
<option value="white-dashed">线</option>
<option value="yellow-solid">线</option>
<option value="white-solid">线</option>
{segment ? <option value="double-yellow-solid">线</option> : null}
</select>
</label>
<Button type="submit">线</Button>
</form>
);
}
function SignalEditor({
state,
signal,
status,
update,
}: {
state: WorkbenchState;
signal?: GeoFeature;
status: (text: string) => void;
update: (features: GeoFeature[]) => Promise<void>;
}) {
const [selected, setSelected] = useState('');
useEffect(() => setSelected(String(signal?.properties?.signal_uid || '')), [signal]);
const feature = state.trafficSignals.assemblies.features.find((item) => item.properties?.signal_uid === selected);
const saveField = (key: string, value: unknown) => {
if (!feature) return;
void update(
state.trafficSignals.assemblies.features.map((item) =>
item === feature ? { ...item, properties: { ...item.properties, [key]: value } } : item,
),
);
};
return (
<section>
<hr />
<h2>绿</h2>
<Button
onClick={() =>
void api
.generateSignals()
.then((next) => {
void update(next.trafficSignals.assemblies.features);
status('已补充 OSM 信号灯');
})
.catch((error: Error) => status(error.message))
}
>
OSM
</Button>
<label>
<select value={selected} onChange={(event) => setSelected(event.target.value)}>
<option value=""></option>
{state.trafficSignals.assemblies.features.map((item) => (
<option key={String(item.properties?.signal_uid)} value={String(item.properties?.signal_uid)}>
{String(item.properties?.display_id || item.properties?.signal_uid)}
</option>
))}
</select>
</label>
{feature ? (
<>
<label>
<input
type="number"
value={Number(feature.properties?.mast_heading_deg || 0)}
onChange={(event) => saveField('mast_heading_deg', Number(event.target.value))}
/>
</label>
<label>
<input
type="number"
value={Number(feature.properties?.mast_reach_m || 0)}
onChange={(event) => saveField('mast_reach_m', Number(event.target.value))}
/>
</label>
<label>
<input
type="number"
value={Number(feature.properties?.face_heading_deg || 0)}
onChange={(event) => saveField('face_heading_deg', Number(event.target.value))}
/>
</label>
<label>
<input
type="checkbox"
checked={Boolean(feature.properties?.enabled)}
onChange={(event) => saveField('enabled', event.target.checked)}
/>{' '}
</label>
<Button
onClick={() =>
void update(state.trafficSignals.assemblies.features.filter((item) => item !== feature)).then(() => {
setSelected('');
status('红绿灯已删除');
})
}
>
</Button>
</>
) : null}
</section>
);
}
function Candidates({
state,
setState,
status,
}: {
state: WorkbenchState;
setState: (state: WorkbenchState) => void;
status: (text: string) => void;
}) {
const candidates = state.debug?.junctionCandidates || [];
if (!candidates.length) return null;
return (
<section>
<hr />
<h2>debug</h2>
{candidates.map((candidate) => (
<Button
key={candidate.id}
onClick={() =>
void api
.acceptJunctionCandidate(candidate.index)
.then((next) => {
setState(next);
status(`已加入 ${next.added.id} 并重新编译`);
})
.catch((error: Error) => status(error.message))
}
>
#{candidate.index}{candidate.nodeCount} {candidate.diameterMeters}
</Button>
))}
</section>
);
}
export default App;

View File

@@ -0,0 +1,88 @@
import { useEffect, useRef } from 'react';
import Map from 'ol/Map';
import View from 'ol/View';
import Select from 'ol/interaction/Select';
import { click } from 'ol/events/condition';
import type { Road, WorkbenchState } from '../types/state';
import { createLayers, updateLayers, type LayerName } from '../map/layers';
interface Props {
state: WorkbenchState;
selected: Road | null;
visible: Partial<Record<LayerName, boolean>>;
scene: boolean;
onSelectRoad: (road: Road) => void;
onFeature: (properties: Record<string, unknown>) => void;
}
export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFeature }: Props) {
const target = useRef<HTMLDivElement>(null);
const mapRef = useRef<Map | null>(null);
const layersRef = useRef<ReturnType<typeof createLayers> | null>(null);
const selectedRef = useRef<Road | null>(selected);
const stateRef = useRef(state);
selectedRef.current = selected;
stateRef.current = state;
useEffect(() => {
if (!target.current) return;
const layers = createLayers(
() => selectedRef.current,
() => scene,
);
layersRef.current = layers;
const map = new Map({
target: target.current,
layers: Object.values(layers),
view: new View({ center: [0, 0], zoom: 2 }),
});
mapRef.current = map;
const select = new Select({
condition: click,
layers: (layer) => layer !== layers.selectedRoad,
hitTolerance: 12,
style: null,
});
map.addInteraction(select);
const listener = ({ selected: values }: { selected: import('ol/Feature').default[] }) => {
const properties = values[0]?.getProperties();
if (!properties) return;
const roadId =
properties.road_id ||
properties.from_road_id ||
(typeof properties.from_lane_id === 'string'
? properties.from_lane_id.slice(5, properties.from_lane_id.lastIndexOf(':'))
: undefined);
const road = stateRef.current.compiled.model.roads.find((item) => item.id === roadId);
if (road) onSelectRoad(road);
onFeature(properties);
};
select.on('select', listener);
return () => {
select.un('select', listener);
map.setTarget(undefined);
};
// Map construction must be once per mounted canvas; state changes update sources below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const layers = layersRef.current;
const map = mapRef.current;
if (!layers || !map) return;
updateLayers(layers, state, selected);
const extent = layers.osm.getSource()!.getExtent();
if (extent && Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
}, [state, selected]);
useEffect(() => {
const layers = layersRef.current;
if (!layers) return;
Object.entries(visible).forEach(([name, enabled]) =>
layers[name as LayerName]?.setVisible(
!scene || !['osm', 'lanes', 'reference', 'gaodeReference', 'connectors'].includes(name)
? enabled !== false
: false,
),
);
layers.native.changed();
layers.sidewalks.changed();
}, [visible, scene]);
return <div className="map" ref={target} aria-label="道路地图" />;
}

View File

@@ -0,0 +1,40 @@
import type { WorkbenchState } from '../types/state';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, { cache: 'no-store', ...init });
const value = (await response.json()) as T & { ok?: boolean; error?: string };
if (!response.ok || value.ok === false) throw new Error(value.error || `HTTP ${response.status}`);
return value;
}
export const api = {
state: () => request<WorkbenchState | { active: false }>('/api/state'),
import: (file: File) => {
const body = new FormData();
body.append('file', file, file.name);
return request<WorkbenchState>('/api/import', { method: 'POST', body });
},
overrides: (overrides: unknown[]) =>
request<{ overrides: WorkbenchState['overrides'] }>('/api/overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema: 'native-road-overrides/v1', overrides }),
}),
compile: () => request<WorkbenchState>('/api/compile', { method: 'POST' }),
signals: (document: unknown) =>
request<Pick<WorkbenchState, 'trafficSignals' | 'trafficRuntime'>>('/api/traffic-signals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(document),
}),
generateSignals: () =>
request<Pick<WorkbenchState, 'trafficSignals' | 'trafficRuntime'>>('/api/traffic-signals/generate', {
method: 'POST',
}),
acceptJunctionCandidate: (index: number) =>
request<WorkbenchState & { added: { id: string } }>('/api/junction-clusters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index }),
}),
};

View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import 'ol/ol.css';
import './styles.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,160 @@
import Feature from 'ol/Feature';
import GeoJSON from 'ol/format/GeoJSON';
import LineString from 'ol/geom/LineString';
import Point from 'ol/geom/Point';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import CircleStyle from 'ol/style/Circle';
import Fill from 'ol/style/Fill';
import RegularShape from 'ol/style/RegularShape';
import Stroke from 'ol/style/Stroke';
import Style from 'ol/style/Style';
import type { Road, WorkbenchState } from '../types/state';
const geojson = new GeoJSON();
const source = () => new VectorSource();
export type LayerName =
| 'reference'
| 'gaodeReference'
| 'native'
| 'sidewalks'
| 'osm'
| 'lanes'
| 'edgeLines'
| 'directionArrows'
| 'markings'
| 'centerLines'
| 'controls'
| 'signals'
| 'connectors'
| 'diagnostics'
| 'selectedRoad';
export function createLayers(onRoad: () => Road | null, scene: () => boolean) {
const simple = (color: string, width = 1) =>
new Style({ fill: new Fill({ color: `${color}55` }), stroke: new Stroke({ color, width }) });
const layers: Record<LayerName, VectorLayer<VectorSource>> = {
reference: new VectorLayer({ source: source(), visible: false, style: simple('#8999a0') }),
gaodeReference: new VectorLayer({ source: source(), style: simple('#2563eb', 1.5) }),
native: new VectorLayer({
source: source(),
style: () => (scene() ? new Style({ fill: new Fill({ color: '#3f4b50' }) }) : simple('#296956')),
}),
sidewalks: new VectorLayer({
source: source(),
style: () => (scene() ? new Style({ fill: new Fill({ color: '#b7b9ad' }) }) : simple('#9b7c40')),
}),
osm: new VectorLayer({
source: source(),
style: (f) =>
new Style({
stroke: new Stroke({
color: f.get('road_id') === onRoad()?.id ? '#006e91' : '#263630',
width: f.get('road_id') === onRoad()?.id ? 5 : 2,
}),
}),
}),
lanes: new VectorLayer({
source: source(),
style: new Style({ stroke: new Stroke({ color: '#f5f6ee', width: 1.5, lineDash: [5, 4] }) }),
}),
edgeLines: new VectorLayer({
source: source(),
visible: false,
style: new Style({ stroke: new Stroke({ color: '#f5f6ee', width: 1 }) }),
}),
directionArrows: new VectorLayer({
source: source(),
style: new Style({ fill: new Fill({ color: '#f5f6ee' }), stroke: new Stroke({ color: '#d9dacf', width: 1 }) }),
}),
markings: new VectorLayer({
source: source(),
style: new Style({ fill: new Fill({ color: '#f5f6ee' }), stroke: new Stroke({ color: '#d9dacf', width: 1 }) }),
}),
centerLines: new VectorLayer({
source: source(),
style: new Style({ fill: new Fill({ color: '#f5be2a' }), stroke: new Stroke({ color: '#d29d16', width: 1 }) }),
}),
controls: new VectorLayer({
source: source(),
style: new Style({ fill: new Fill({ color: '#f5f6ee' }), stroke: new Stroke({ color: '#d9dacf', width: 1 }) }),
}),
signals: new VectorLayer({
source: source(),
style: new Style({
image: new RegularShape({
points: 4,
radius: 6,
angle: Math.PI / 4,
fill: new Fill({ color: '#263630' }),
stroke: new Stroke({ color: '#fff', width: 2 }),
}),
}),
}),
connectors: new VectorLayer({
source: source(),
style: new Style({ stroke: new Stroke({ color: '#ad3a76', width: 2, lineDash: [7, 5] }) }),
}),
diagnostics: new VectorLayer({
source: source(),
style: (f) =>
new Style({
image: new CircleStyle({
radius: 6,
fill: new Fill({ color: f.get('severity') === 'error' ? '#bf3b2e' : '#d49318' }),
stroke: new Stroke({ color: '#fff', width: 1 }),
}),
}),
}),
selectedRoad: new VectorLayer({
source: source(),
style: new Style({ stroke: new Stroke({ color: '#00a5cf', width: 8 }) }),
}),
};
return layers;
}
function features(value: unknown) {
return geojson.readFeatures((value || { type: 'FeatureCollection', features: [] }) as object, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
}
export function updateLayers(layers: ReturnType<typeof createLayers>, state: WorkbenchState, selected: Road | null) {
const get = (name: LayerName) => layers[name].getSource()!;
const put = (name: LayerName, value: unknown) => {
const target = get(name);
target.clear();
target.addFeatures(features(value));
};
put('reference', state.layers.osm2streetsRoadSurface);
put('gaodeReference', state.junctionReference?.converted);
put('native', state.layers.nativeRoadSurface);
get('native').addFeatures(features(state.layers.nativeIntersectionSurface));
put('sidewalks', state.layers.nativeSidewalkSurface);
const roads = state.compiled.model.roads.map(
(road) =>
new Feature({ geometry: new LineString(road.centerline).transform('EPSG:4326', 'EPSG:3857'), road_id: road.id }),
);
put('osm', { type: 'FeatureCollection', features: [] });
get('osm').addFeatures(roads);
put('lanes', state.layers.laneCenterlines);
put('edgeLines', state.layers.edgeLines);
put('directionArrows', state.layers.directionArrows);
put('markings', state.layers.laneSeparators);
get('markings').addFeatures(features(state.layers.turnArrows));
put('centerLines', state.layers.centerLines);
put('controls', state.layers.crosswalks);
get('controls').addFeatures(features(state.layers.vehicleStopLines));
put('signals', state.trafficSignals.assemblies);
put('connectors', state.layers.connectors);
put('diagnostics', {
type: 'FeatureCollection',
features: state.compiled.diagnostics
.filter((item) => item.geometry)
.map(({ geometry, ...properties }) => ({ type: 'Feature', properties, geometry: geometry! })),
});
put('selectedRoad', { type: 'FeatureCollection', features: [] });
if (selected)
get('selectedRoad').addFeature(
new Feature({ geometry: new LineString(selected.centerline).transform('EPSG:4326', 'EPSG:3857') }),
);
}

View File

@@ -0,0 +1,223 @@
:root {
font-family: system-ui, sans-serif;
color: #202523;
background: #eef1ef;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
header {
height: 50px;
display: flex;
gap: 12px;
align-items: center;
padding: 0 16px;
background: #183a32;
color: white;
}
header span {
color: #c9d8d2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dirty {
color: #ffe08a;
font-weight: 700;
}
.scene {
margin-left: auto;
white-space: nowrap;
}
main {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 320px;
height: calc(100vh - 50px);
}
aside {
overflow: auto;
background: white;
padding: 16px;
}
.issues {
border-right: 1px solid #d5dfda;
}
.inspector {
border-left: 1px solid #d5dfda;
}
.map {
min-height: 400px;
background: #d7e2de;
}
.button {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #82988f;
background: white;
color: #1d392f;
padding: 7px 10px;
border-radius: 3px;
cursor: pointer;
text-decoration: none;
font: inherit;
}
.button:disabled {
opacity: 0.55;
cursor: default;
}
header .button {
white-space: nowrap;
}
h1 {
font-size: 16px;
margin: 0 0 8px;
}
h2 {
font-size: 14px;
margin: 12px 0 8px;
}
label,
output {
display: block;
margin: 10px 0;
}
input[type='number'] {
display: block;
width: 100%;
padding: 7px;
border: 1px solid #aab8b2;
}
dl {
display: grid;
grid-template-columns: 1fr auto;
gap: 5px 10px;
margin: 0;
}
dt {
color: #52615b;
}
dd {
margin: 0;
}
hr {
border: 0;
border-top: 1px solid #dde4e1;
margin: 16px 0;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
.issues li .button {
width: 100%;
text-align: left;
margin: 4px 0;
background: #fff7e5;
border-color: #e7c67b;
}
.segmented {
display: flex;
}
.segmented .button {
flex: 1;
border-radius: 0;
padding: 6px 4px;
}
.segmented .active {
background: #286956;
border-color: #286956;
color: white;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #52615b;
}
.import-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.import-screen form {
width: min(460px, 100%);
background: white;
padding: 28px;
box-shadow: 0 8px 30px #0003;
}
.import-screen input {
margin: 18px 0;
}
@media (max-width: 900px) {
header {
height: auto;
min-height: 50px;
flex-wrap: wrap;
padding-block: 8px;
}
main {
grid-template-columns: minmax(0, 1fr);
height: calc(100vh - 66px);
}
.issues {
display: none;
}
.inspector {
position: absolute;
right: 0;
bottom: 0;
width: min(360px, 100%);
max-height: 55vh;
border-top: 1px solid #d5dfda;
}
}
.overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.overview-grid div,
.overview-status,
.overview-diagnostics {
border: 1px solid #dce5e1;
padding: 7px;
background: #f8fbfa;
}
.overview-grid span,
.overview-status span,
.overview-diagnostics > span {
display: block;
color: #52615b;
font-size: 12px;
}
.overview-grid strong {
display: block;
margin-top: 2px;
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.overview-status {
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.overview-diagnostics {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.overview-diagnostics > span {
width: 100%;
}
.overview-diagnostics strong {
background: #fff4dc;
color: #7c5200;
padding: 2px 5px;
font-size: 12px;
}

View File

@@ -0,0 +1,92 @@
export type Position = [number, number];
export interface GeoFeature {
type: 'Feature';
properties?: Record<string, unknown>;
geometry: { type: string; coordinates: unknown } | null;
}
export interface GeoJson {
type: 'FeatureCollection';
features: GeoFeature[];
}
export interface Road {
id: string;
segmentId: string;
centerline: Position[];
sourceNodeIds: string[];
osmWayIds: string[];
tags: Record<string, string>;
highway: string;
direction: string;
widthMeters: number;
laneCount: number;
sidewalkLeft: boolean;
sidewalkRight: boolean;
provenance?: unknown;
appliedOverrideIds?: string[];
}
export interface Endpoint {
id: string;
roadId: string;
side: 'start' | 'end';
nodeId: string;
coordinate: Position;
}
export interface Connection {
id: string;
fromEndpointId: string;
toEndpointId: string;
enabled: boolean;
}
export interface Movement {
id: string;
connectionId: string;
fromRoadId: string;
toRoadId: string;
fromLaneId: string;
toLaneId: string;
turn: string;
geometryPublished?: boolean;
geometryStatus?: string;
nodeId?: string;
provenance?: string;
}
export interface Override {
id: string;
kind: string;
[key: string]: unknown;
}
export interface JunctionCandidate {
index: number;
id: string;
nodeIds: string[];
nodeCount: number;
diameterMeters: number;
template: string;
coreRadiusMeters: number;
}
export interface Diagnostic {
id: string;
message: string;
rule: string;
subjectId?: string;
endpointId?: string;
severity?: string;
geometry?: { type: string; coordinates: unknown };
manualCandidates?: Array<{ roadId: string; distanceMeters: number }>;
}
export interface WorkbenchState {
active?: boolean;
areaId: string;
compiled: {
model: { roads: Road[]; endpoints: Endpoint[]; connections: Connection[] };
movements?: Movement[];
diagnostics: Diagnostic[];
};
overrides: { schema: string; overrides: Override[] };
trafficSignals: { provenance: string; assemblies: GeoJson };
trafficRuntime: { signals: unknown[] };
comparison: Record<string, unknown>;
layers: Record<string, GeoJson | null>;
junctionReference?: { converted: GeoJson };
debug?: { junctionCandidates: JunctionCandidate[] } | null;
}

View File

@@ -0,0 +1,12 @@
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react';
export function Button({
children,
className = '',
...props
}: PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>) {
return (
<button className={`button ${className}`} {...props}>
{children}
</button>
);
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"skipLibCheck": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
export default defineConfig({
root: resolve(__dirname),
base: '/',
build: { outDir: resolve(__dirname, 'dist'), emptyOutDir: true },
server: { port: 5173, proxy: { '/api': 'http://127.0.0.1:8787' } },
});

View File

@@ -1,32 +1,57 @@
#!/usr/bin/env node
"use strict";
'use strict';
const fs = require("fs");
const http = require("http");
const path = require("path");
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../src/compile/native-road");
const { generate, validateDocument, runtime } = require("../src/native-traffic-signals");
const { convertGeoJson } = require("../src/reference/gaode");
const { exportNativeRoadPackage } = require("../src/export/native-road-package");
const { compileInput } = require("../src/compile/compiler");
const fs = require('fs');
const http = require('http');
const path = require('path');
const { loadOverrides, validateOverrides, writeJsonAtomic } = require('../src/compile/native-road');
const { generate, validateDocument, runtime } = require('../src/native-traffic-signals');
const { convertGeoJson } = require('../src/reference/gaode');
const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const { compileInput } = require('../src/compile/compiler');
function startWorkbench({ area = null, input = null, inputFile = null, repoRoot = process.cwd(), dataRoot = path.join(repoRoot, "workbench-data"), configPath = null, compileFresh = null, readAreaConfig = null, junctionReference = null, debug = false, port = 8787 }) {
if (typeof junctionReference === "string") junctionReference = readJunctionReference(junctionReference);
function startWorkbench({
area = null,
input = null,
inputFile = null,
repoRoot = process.cwd(),
dataRoot = path.join(repoRoot, 'workbench-data'),
configPath = null,
compileFresh = null,
readAreaConfig = null,
junctionReference = null,
debug = false,
port = 8787,
}) {
if (typeof junctionReference === 'string') junctionReference = readJunctionReference(junctionReference);
// `--debug` surfaces advisory compiler findings that have no geometry layer of
// their own — currently the complex-junction candidates. Off by default so the
// normal editing view stays uncluttered.
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
const session = { area, context: { repoRoot, configPath, compileFresh, readAreaConfig }, junctionReference, debug, dataRoot };
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw new Error('--port must be an integer in [1024, 65535].');
const session = {
area,
context: { repoRoot, configPath, compileFresh, readAreaConfig },
junctionReference,
debug,
dataRoot,
};
if (input) {
session.context.compileFresh = () => { const compiled = compileInput(input); session.area = compiled.area; return compiled; };
session.context.compileFresh = () => {
const compiled = compileInput(input);
session.area = compiled.area;
return compiled;
};
session.context.compileFresh();
}
const server = http.createServer((request, response) => handle(request, response, session));
server.on("error", (error) => {
server.on('error', (error) => {
console.error(`Road Workbench failed to listen: ${error.message}`);
process.exitCode = 1;
});
server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? " (debug: 复杂路口候选已开启)" : ""}`));
server.listen(port, '127.0.0.1', () =>
console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? ' (debug: 复杂路口候选已开启)' : ''}`),
);
return server;
}
@@ -35,73 +60,152 @@ function handle(request, response, session) {
const context = session.context;
const junctionReference = session.junctionReference;
const debug = session.debug;
const url = new URL(request.url, "http://127.0.0.1");
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(__dirname, "client", "index.html"), "text/html; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(__dirname, "client", "app.js"), "text/javascript; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(__dirname, "client", "app.css"), "text/css; charset=utf-8");
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname, context.repoRoot);
if (request.method === "GET" && url.pathname === "/api/state") return area ? sendJson(response, 200, state(area, junctionReference, debug)) : sendJson(response, 200, { active: false });
if (request.method === "GET" && url.pathname === "/api/session") return sendJson(response, 200, { active: Boolean(session.area), areaId: session.area?.id || null });
if (request.method === "POST" && url.pathname === "/api/import") return readUpload(request, session).then((result) => sendJson(response, 200, { ok: true, areaId: result.area.id, ...state(result.area, junctionReference, debug) })).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "GET" && url.pathname === "/api/export.zip") return Promise.resolve().then(() => {
if (!area) throw new Error("请先导入 OSM 文件。");
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
response.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${area.id}.native-road.zip"`,
"Content-Length": exported.bytes.length,
"Cache-Control": "no-store",
});
response.end(Buffer.from(exported.bytes));
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => {
if (!area) throw new Error("请先导入 OSM 文件。");
const document = validateDocument(body, fs.readFileSync(area.input, "utf8"));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/traffic-signals/generate") return Promise.resolve().then(() => {
if (!area) throw new Error("请先导入 OSM 文件。");
const compiled = readCompiled(area);
const generated = generate(fs.readFileSync(area.input, "utf8"), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "vehicle_stop_lines.geojson")), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "intersection_surface.geojson")));
const current = validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"));
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
current.assemblies.features.push(...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid)));
writeJsonAtomic(area.outputs.nativeTrafficSignals, current);
sendJson(response, 200, { ok: true, trafficSignals: current, runtime: runtime(current), generated: generated.assemblies.features.length, compiled: Boolean(compiled) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
if (!area) throw new Error("请先导入 OSM 文件。");
const compiled = readCompiled(area);
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
sendJson(response, 200, { ok: true, overrides });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/junction-clusters") return readBody(request).then((body) => {
if (!area) throw new Error("请先导入 OSM 文件。");
if (!debug) throw new Error("该接口仅在 --debug 模式下可用。");
const added = addJunctionCluster(context.configPath, body, readCompiled(area), context.readAreaConfig, context.repoRoot);
context.compileFresh();
const refreshed = context.readAreaConfig(context.configPath, { repoRoot: context.repoRoot });
sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
if (!area || typeof context.compileFresh !== "function") throw new Error("请先导入 OSM 文件。");
context.compileFresh();
sendJson(response, 200, state(session.area, junctionReference, debug));
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
sendJson(response, 404, { error: "Not found" });
const url = new URL(request.url, 'http://127.0.0.1');
if (request.method === 'GET' && url.pathname === '/') return sendWorkbenchApp(response);
if (request.method === 'GET' && url.pathname.startsWith('/vendor/'))
return sendVendorFile(response, url.pathname, context.repoRoot);
if (request.method === 'GET' && url.pathname === '/api/state')
return area
? sendJson(response, 200, state(area, junctionReference, debug))
: sendJson(response, 200, { active: false });
if (request.method === 'GET' && url.pathname === '/api/session')
return sendJson(response, 200, { active: Boolean(session.area), areaId: session.area?.id || null });
if (request.method === 'POST' && url.pathname === '/api/import')
return readUpload(request, session)
.then((result) =>
sendJson(response, 200, { ok: true, areaId: result.area.id, ...state(result.area, junctionReference, debug) }),
)
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'GET' && url.pathname === '/api/export.zip')
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
response.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${area.id}.native-road.zip"`,
'Content-Length': exported.bytes.length,
'Cache-Control': 'no-store',
});
response.end(Buffer.from(exported.bytes));
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/traffic-signals')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
const document = validateDocument(body, fs.readFileSync(area.input, 'utf8'));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/traffic-signals/generate')
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
const compiled = readCompiled(area);
const generated = generate(
fs.readFileSync(area.input, 'utf8'),
readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'vehicle_stop_lines.geojson')),
readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'intersection_surface.geojson')),
);
const current = validateDocument(
readJson(area.outputs.nativeTrafficSignals),
fs.readFileSync(area.input, 'utf8'),
);
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
current.assemblies.features.push(
...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid)),
);
writeJsonAtomic(area.outputs.nativeTrafficSignals, current);
sendJson(response, 200, {
ok: true,
trafficSignals: current,
runtime: runtime(current),
generated: generated.assemblies.features.length,
compiled: Boolean(compiled),
});
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/overrides')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
const compiled = readCompiled(area);
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
sendJson(response, 200, { ok: true, overrides });
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/junction-clusters')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
if (!debug) throw new Error('该接口仅在 --debug 模式下可用。');
const added = addJunctionCluster(
context.configPath,
body,
readCompiled(area),
context.readAreaConfig,
context.repoRoot,
);
context.compileFresh();
const refreshed = context.readAreaConfig(context.configPath, { repoRoot: context.repoRoot });
sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) });
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/compile')
return Promise.resolve()
.then(() => {
if (!area || typeof context.compileFresh !== 'function') throw new Error('请先导入 OSM 文件。');
context.compileFresh();
sendJson(response, 200, state(session.area, junctionReference, debug));
})
.catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
if (request.method === 'GET' && !url.pathname.startsWith('/api/')) return sendWorkbenchAsset(response, url.pathname);
sendJson(response, 404, { error: 'Not found' });
}
function state(area, junctionReference = null, debug = false) {
const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, "road_surface.geojson") : null;
const osm2streetsRoadSurface = area.outputs.geojsonDir
? path.join(area.outputs.geojsonDir, 'road_surface.geojson')
: null;
const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals)
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"))
: { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } };
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, 'utf8'))
: {
schema: 'native-traffic-signals/v1',
provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] },
};
const trafficRuntime = runtime(trafficSignals);
const compiled = readCompiled(area);
return { areaId: area.id, debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null, compiled, overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), junctionReference, layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: osm2streetsRoadSurface && fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
return {
areaId: area.id,
debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null,
compiled,
overrides: loadOverrides(area.outputs.nativeRoadOverrides),
trafficSignals,
trafficRuntime,
comparison: readJson(path.join(nativeDir, 'comparison.json')),
junctionReference,
layers: {
nativeRoadSurface: readLayer(path.join(nativeDir, 'layers', 'road_surface.geojson')),
edgeLines: readLayer(path.join(nativeDir, 'layers', 'edge_lines.geojson')),
nativeSidewalkSurface: readLayer(path.join(nativeDir, 'layers', 'sidewalk_surface.geojson')),
nativeIntersectionSurface: readLayer(path.join(nativeDir, 'layers', 'intersection_surface.geojson')),
laneCenterlines: readLayer(path.join(nativeDir, 'layers', 'lane_centerlines.geojson')),
laneSeparators: readLayer(path.join(nativeDir, 'layers', 'lane_separators.geojson')),
centerLines: readLayer(path.join(nativeDir, 'layers', 'center_lines.geojson')),
directionArrows: readLayer(path.join(nativeDir, 'layers', 'direction_arrows.geojson')),
turnArrows: readLayer(path.join(nativeDir, 'layers', 'turn_arrows.geojson')),
crosswalks: readLayer(path.join(nativeDir, 'layers', 'crosswalks.geojson')),
vehicleStopLines: readLayer(path.join(nativeDir, 'layers', 'vehicle_stop_lines.geojson')),
connectors: readLayer(path.join(nativeDir, 'layers', 'connectors.geojson')),
osm2streetsRoadSurface:
osm2streetsRoadSurface && fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null,
},
};
}
// The compiler reports candidates as advisory diagnostics. Lift them into their
// own payload with a stable index so the map can label them "#1, #2, ..." and
@@ -113,16 +217,16 @@ function state(area, junctionReference = null, debug = false) {
// later command, and the file is git-tracked so a bad accept stays revertible.
function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot) {
const index = Number(body?.index);
if (!Number.isInteger(index)) throw new Error("请求缺少候选编号 index。");
if (!Number.isInteger(index)) throw new Error('请求缺少候选编号 index。');
const candidate = junctionCandidates(compiled).find((item) => item.index === index);
if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`);
const raw = readJson(configPath);
const templates = raw.nativeRoad?.junctionTemplates;
if (!templates) throw new Error("区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。");
if (!templates) throw new Error('区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。');
const clusters = Array.isArray(templates.clusters) ? templates.clusters : [];
const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String)));
const clash = candidate.nodeIds.filter((nodeId) => taken.has(String(nodeId)));
if (clash.length) throw new Error(`节点 ${clash.join("、")} 已属于其他复杂路口配置。`);
if (clash.length) throw new Error(`节点 ${clash.join('、')} 已属于其他复杂路口配置。`);
const id = uniqueClusterId(`cluster-${candidate.nodeIds[0]}`, new Set(clusters.map((cluster) => cluster.id)));
const cluster = {
id,
@@ -132,7 +236,13 @@ function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot
outerRadiusExtraMeters: 18,
nodeIds: candidate.nodeIds.map(String),
};
const next = { ...raw, nativeRoad: { ...raw.nativeRoad, junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] } } };
const next = {
...raw,
nativeRoad: {
...raw.nativeRoad,
junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] },
},
};
const staging = `${configPath}.candidate-${process.pid}.json`;
fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`);
try {
@@ -148,55 +258,176 @@ function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot
function uniqueClusterId(base, taken) {
if (!taken.has(base)) return base;
for (let suffix = 2; suffix < 100; suffix += 1) if (!taken.has(`${base}-${suffix}`)) return `${base}-${suffix}`;
throw new Error("无法生成唯一的 cluster id。");
throw new Error('无法生成唯一的 cluster id。');
}
function junctionCandidates(compiled) {
return (compiled?.diagnostics || [])
.filter((item) => item.rule === "complex-junction-candidate" && item.suggestedCluster)
.sort((first, second) => second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount || first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters)
.map((item, index) => ({ index: index + 1, id: item.id, message: item.message, coordinate: item.geometry?.coordinates || null, ...item.suggestedCluster }));
.filter((item) => item.rule === 'complex-junction-candidate' && item.suggestedCluster)
.sort(
(first, second) =>
second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount ||
first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters,
)
.map((item, index) => ({
index: index + 1,
id: item.id,
message: item.message,
coordinate: item.geometry?.coordinates || null,
...item.suggestedCluster,
}));
}
function readJunctionReference(file) {
if (!fs.existsSync(file)) throw new Error(`Junction reference not found: ${file}`);
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, "utf8")));
return { source: file, coordinateSystem: "GCJ-02", converted };
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, 'utf8')));
return { source: file, coordinateSystem: 'GCJ-02', converted };
}
function readUpload(request, session) {
return readMultipart(request, 20 * 1024 * 1024).then(({ filename, data }) => {
if (!filename || !/\.osm$/i.test(filename)) throw new Error("请选择 .osm 文件。");
if (!data.length) throw new Error("OSM 文件不能为空。");
const base = path.basename(filename, path.extname(filename)).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "osm-import";
if (!filename || !/\.osm$/i.test(filename)) throw new Error('请选择 .osm 文件。');
if (!data.length) throw new Error('OSM 文件不能为空。');
const base =
path
.basename(filename, path.extname(filename))
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase() || 'osm-import';
fs.mkdirSync(session.dataRoot, { recursive: true });
const root = fs.mkdtempSync(path.join(session.dataRoot, "import-"));
const root = fs.mkdtempSync(path.join(session.dataRoot, 'import-'));
const areaId = `${base}-${path.basename(root).slice(-6)}`;
const outRoot = path.join(root, "outputs");
const input = { areaId, osmFile: path.join(root, "source.osm"), outDir: path.join(outRoot, "native-road"), stagingDir: path.join(outRoot, "_pipeline"), overridesFile: path.join(root, "native-road-overrides.json"), trafficSignalsFile: path.join(root, "native-traffic-signals.json"), options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } } };
const outRoot = path.join(root, 'outputs');
const input = {
areaId,
osmFile: path.join(root, 'source.osm'),
outDir: path.join(outRoot, 'native-road'),
stagingDir: path.join(outRoot, '_pipeline'),
overridesFile: path.join(root, 'native-road-overrides.json'),
trafficSignalsFile: path.join(root, 'native-traffic-signals.json'),
options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } },
};
fs.writeFileSync(input.osmFile, data);
fs.writeFileSync(input.overridesFile, JSON.stringify({ schema: "native-road-overrides/v1", overrides: [] }, null, 2));
fs.writeFileSync(input.trafficSignalsFile, JSON.stringify({ schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } }, null, 2));
try { const compiled = compileInput(input); session.area = compiled.area; return compiled; } catch (error) { fs.rmSync(root, { recursive: true, force: true }); throw error; }
fs.writeFileSync(
input.overridesFile,
JSON.stringify({ schema: 'native-road-overrides/v1', overrides: [] }, null, 2),
);
fs.writeFileSync(
input.trafficSignalsFile,
JSON.stringify(
{
schema: 'native-traffic-signals/v1',
provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] },
},
null,
2,
),
);
try {
const compiled = compileInput(input);
session.area = compiled.area;
return compiled;
} catch (error) {
fs.rmSync(root, { recursive: true, force: true });
throw error;
}
});
}
function readMultipart(request, limit) { return new Promise((resolve, reject) => {
const type = request.headers["content-type"] || ""; const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type); if (!match) return reject(new Error("请使用 multipart/form-data 上传 OSM 文件。"));
const boundary = `--${match[1] || match[2]}`; const chunks = []; let size = 0;
request.on("data", (chunk) => { size += chunk.length; if (size > limit) { reject(new Error("上传文件超过 20 MB 限制。")); request.destroy(); return; } chunks.push(chunk); });
request.on("error", reject); request.on("end", () => { const body = Buffer.concat(chunks); const start = body.indexOf(Buffer.from("\r\n\r\n")); const end = body.lastIndexOf(Buffer.from(`\r\n${boundary}--`)); if (start < 0 || end < start) return reject(new Error("上传内容格式无效。")); const header = body.slice(0, start).toString(); const name = /filename="([^"]*)"/i.exec(header)?.[1] || "upload.osm"; resolve({ filename: name, data: body.slice(start + 4, end) }); });
}); }
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
function readLayer(file) { return fs.existsSync(file) ? readJson(file) : { type: "FeatureCollection", features: [] }; }
function readBody(request) { return new Promise((resolve, reject) => { let body = ""; request.setEncoding("utf8"); request.on("data", (part) => { body += part; if (body.length > 1024 * 1024) request.destroy(); }); request.on("end", () => { try { resolve(JSON.parse(body)); } catch (_) { reject(new Error("Request body must be JSON.")); } }); request.on("error", reject); }); }
function sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); }
function readMultipart(request, limit) {
return new Promise((resolve, reject) => {
const type = request.headers['content-type'] || '';
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type);
if (!match) return reject(new Error('请使用 multipart/form-data 上传 OSM 文件。'));
const boundary = `--${match[1] || match[2]}`;
const chunks = [];
let size = 0;
request.on('data', (chunk) => {
size += chunk.length;
if (size > limit) {
reject(new Error('上传文件超过 20 MB 限制。'));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('error', reject);
request.on('end', () => {
const body = Buffer.concat(chunks);
const start = body.indexOf(Buffer.from('\r\n\r\n'));
const end = body.lastIndexOf(Buffer.from(`\r\n${boundary}--`));
if (start < 0 || end < start) return reject(new Error('上传内容格式无效。'));
const header = body.slice(0, start).toString();
const name = /filename="([^"]*)"/i.exec(header)?.[1] || 'upload.osm';
resolve({ filename: name, data: body.slice(start + 4, end) });
});
});
}
function readCompiled(area) {
return readJson(path.join(area.outputs.nativeRoadDir, 'compiled.json'));
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function readLayer(file) {
return fs.existsSync(file) ? readJson(file) : { type: 'FeatureCollection', features: [] };
}
function readBody(request) {
return new Promise((resolve, reject) => {
let body = '';
request.setEncoding('utf8');
request.on('data', (part) => {
body += part;
if (body.length > 1024 * 1024) request.destroy();
});
request.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (_) {
reject(new Error('Request body must be JSON.'));
}
});
request.on('error', reject);
});
}
function sendWorkbenchApp(response) {
const built = path.join(__dirname, 'client', 'dist', 'index.html');
return sendFile(
response,
fs.existsSync(built) ? built : path.join(__dirname, 'client', 'index.html'),
'text/html; charset=utf-8',
);
}
function sendWorkbenchAsset(response, pathname) {
const root = path.join(__dirname, 'client', 'dist');
const file = path.resolve(root, `.${pathname}`);
if (file.startsWith(`${root}${path.sep}`) && fs.existsSync(file) && fs.statSync(file).isFile()) {
const extension = path.extname(file);
const types = {
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.woff2': 'font/woff2',
};
return sendFile(response, file, types[extension] || 'application/octet-stream');
}
return sendWorkbenchApp(response);
}
function sendFile(response, file, type) {
response.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' });
fs.createReadStream(file).pipe(response);
}
function sendVendorFile(response, pathname, repoRoot) {
const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname);
if (!match) return sendJson(response, 404, { error: "Not found" });
const root = path.join(repoRoot, "node_modules", match[1]);
if (!match) return sendJson(response, 404, { error: 'Not found' });
const root = path.join(repoRoot, 'node_modules', match[1]);
const file = path.resolve(root, match[2]);
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return sendJson(response, 404, { error: "Not found" });
return sendFile(response, file, file.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile())
return sendJson(response, 404, { error: 'Not found' });
return sendFile(response, file, file.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8');
}
function sendJson(response, status, value) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(`${JSON.stringify(value)}\n`);
}
function sendJson(response, status, value) { response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); response.end(`${JSON.stringify(value)}\n`); }
module.exports = { startWorkbench, readMultipart };