feat: migrate workbench to React
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user