639 lines
22 KiB
TypeScript
639 lines
22 KiB
TypeScript
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;
|