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

@@ -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;