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;

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>
);
}