"use strict"; const canvas = document.querySelector("#map"); const context = canvas.getContext("2d"); const status = document.querySelector("#status"); const form = document.querySelector("#road-form"); const areaLabel = document.querySelector("#area"); const widthInput = document.querySelector("#width"); const lanesInput = document.querySelector("#lanes"); const leftInput = document.querySelector("#left"); const rightInput = document.querySelector("#right"); const evidence = document.querySelector("#evidence"); const saveButton = document.querySelector("#save"); const compileButton = document.querySelector("#compile"); const diagnostics = document.querySelector("#diagnostics"); const directionSwitch = document.querySelector("#direction-switch"); const addConnectionButton = document.querySelector("#add-connection"); const movementSummary = document.querySelector("#movement-summary"); let state; let selected = null; let staged = []; function message(text) { status.textContent = text; } function roadLabel(road) { return road.tags.name || `${road.highway}(OSM ${road.osmWayIds.join(", ")})`; } function coord(point) { const b = state.bounds; return [(point[0] - b.minX) / (b.maxX - b.minX) * canvas.width, canvas.height - (point[1] - b.minY) / (b.maxY - b.minY) * canvas.height]; } function setBounds() { const points = state.compiled.model.roads.flatMap((road) => road.centerline); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const pad = Math.max((Math.max(...xs) - Math.min(...xs)) * 0.06, 0.0001); state.bounds = { minX: Math.min(...xs) - pad, maxX: Math.max(...xs) + pad, minY: Math.min(...ys) - pad, maxY: Math.max(...ys) + pad }; } function resize() { canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; draw(); } function polygon(feature, fill) { const ring = feature.geometry?.coordinates?.[0]; if (!ring) return; context.beginPath(); ring.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.fillStyle = fill; context.fill(); } function layerLine(feature, stroke, width, dashed) { const points = feature.geometry?.coordinates; if (!points?.length) return; context.save(); context.setLineDash(dashed ? [5 * devicePixelRatio, 4 * devicePixelRatio] : []); context.beginPath(); points.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.strokeStyle = stroke; context.lineWidth = width * devicePixelRatio; context.stroke(); context.restore(); } function draw() { if (!state) return; context.clearRect(0, 0, canvas.width, canvas.height); const layers = state.layers || {}; for (const feature of layers.osm2streetsRoadSurface?.features || []) polygon(feature, "#9ba8ae55"); for (const feature of layers.nativeRoadSurface?.features || []) polygon(feature, "#28695666"); for (const feature of layers.nativeIntersectionSurface?.features || []) polygon(feature, "#0e786066"); for (const feature of layers.laneCenterlines?.features || []) { const active = feature.properties.road_id === selected?.id; layerLine(feature, active ? "#006e91" : "#f5f6ee", active ? 2.4 : 1.2, true); } for (const feature of layers.connectors?.features || []) { if (!effectiveConnectorEnabled(feature.properties)) continue; const active = feature.properties.from_lane_id.startsWith(`lane:${selected?.id}:`); layerLine(feature, active ? "#d1226f" : "#ad3a76", active ? 3 : 1.8, true); } for (const road of state.compiled.model.roads) { context.beginPath(); road.centerline.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.strokeStyle = road.id === selected?.id ? "#006e91" : "#263630"; context.lineWidth = (road.id === selected?.id ? 4 : 2) * devicePixelRatio; context.stroke(); } if (selected) drawDirectionArrow(selected); for (const diagnostic of state.compiled.diagnostics) { if (!diagnostic.geometry) continue; const point = coord(diagnostic.geometry.coordinates); const isJunction = diagnostic.rule === "ordinary-junction-surface"; context.fillStyle = isJunction ? "#0e7860" : diagnostic.severity === "error" ? "#bf3b2e" : "#d49318"; context.beginPath(); context.arc(...point, isJunction ? 4 * devicePixelRatio : 5 * devicePixelRatio, 0, Math.PI * 2); context.fill(); } if (state.focusedDiagnostic?.geometry) { const point = coord(state.focusedDiagnostic.geometry.coordinates); context.strokeStyle = "#006e91"; context.lineWidth = 3 * devicePixelRatio; context.beginPath(); context.arc(...point, 11 * devicePixelRatio, 0, Math.PI * 2); context.stroke(); } } function drawDirectionArrow(road) { const middle = Math.max(1, Math.floor(road.centerline.length / 2)); const a = coord(road.centerline[middle - 1]); const b = coord(road.centerline[middle]); const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); const size = 10 * devicePixelRatio; context.save(); context.translate(b[0], b[1]); context.rotate(angle); context.fillStyle = "#006e91"; context.beginPath(); context.moveTo(size, 0); context.lineTo(-size * 0.8, -size * 0.6); context.lineTo(-size * 0.8, size * 0.6); context.closePath(); context.fill(); context.restore(); } function select(road) { selected = road; form.hidden = !road; document.querySelector("#hint").hidden = Boolean(road); if (!road) return; document.querySelector("#road-name").textContent = `${roadLabel(road)}(${road.direction === "forward" ? "沿 OSM 方向行驶" : "逆 OSM 方向行驶"})`; widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight; evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 名称: road.tags.name || "未标注", 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2); renderMovementSummary(road); renderDirectionSwitch(road); renderConnections(road); draw(); } function renderMovementSummary(road) { const connectors = (state.layers.connectors?.features || []).filter((feature) => feature.properties.from_lane_id.startsWith(`lane:${road.id}:`) && effectiveConnectorEnabled(feature.properties)); const byTurn = connectors.reduce((result, feature) => { result[feature.properties.turn] = (result[feature.properties.turn] || 0) + 1; return result; }, {}); const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; movementSummary.textContent = connectors.length ? `当前方向已生成 ${connectors.length} 条路径:${Object.entries(byTurn).map(([turn, count]) => `${labels[turn] || turn} ${count}`).join(",")}` : "当前方向没有已生成的转向路径"; } function renderDirectionSwitch(road) { directionSwitch.innerHTML = ""; const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(",")); if (alternatives.length < 2) { directionSwitch.textContent = "单向道路:沿 OSM 节点顺序行驶"; return; } const note = document.createElement("label"); note.textContent = "编辑方向(地图蓝色箭头表示当前方向)"; directionSwitch.append(note); for (const item of alternatives) { const button = document.createElement("button"); button.type = "button"; button.textContent = item.direction === "forward" ? "沿 OSM 节点顺序" : "逆 OSM 节点顺序"; button.disabled = item.id === road.id; button.onclick = () => select(item); directionSwitch.append(button); } } function renderConnections(road) { const box = document.querySelector("#connections"); box.innerHTML = ""; const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end"); addConnectionButton.hidden = !endpoint; addConnectionButton.onclick = () => renderAdditionalConnections(road, endpoint); const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id); if (!rows.length) { box.textContent = "当前行驶方向到达道路终点后,没有可编辑的驶出道路。"; return; } const intro = document.createElement("p"); intro.textContent = "到达终点路口后,允许驶入:"; box.append(intro); const seen = new Set(); for (const connection of rows) { const targetEndpoint = state.compiled.model.endpoints.find((item) => item.id === connection.toEndpointId); const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint?.roadId); if (!target || seen.has(target.id)) continue; seen.add(target.id); const input = document.createElement("input"); const label = document.createElement("label"); input.type = "checkbox"; input.checked = effectiveConnectionEnabled(connection); input.onchange = () => { stageConnection(connection, input.checked); renderConnections(road); draw(); }; label.append(input, ` ${turnName(road, target)}:${roadLabel(target)}`); box.append(label); renderLaneConnectionControls(box, connection); } } function renderLaneConnectionControls(box, connection) { const connectors = state.layers.connectors?.features.filter((feature) => feature.properties.connection_id === connection.id) || []; const rows = connectors.map((feature) => ({ from_lane_id: feature.properties.from_lane_id, to_lane_id: feature.properties.to_lane_id, enabled: true })); const sourceRoad = connection.fromEndpointId.slice("endpoint:".length).replace(/:end$/, ""); const targetRoad = connection.toEndpointId.slice("endpoint:".length).replace(/:start$/, ""); for (const override of state.overrides.overrides.filter((item) => item.kind === "lane-connection" && item.fromLaneId.startsWith(`lane:${sourceRoad}:`) && item.toLaneId.startsWith(`lane:${targetRoad}:`))) { if (!rows.some((row) => row.from_lane_id === override.fromLaneId && row.to_lane_id === override.toLaneId)) rows.push({ from_lane_id: override.fromLaneId, to_lane_id: override.toLaneId, enabled: override.enabled }); } for (const row of rows) appendLaneConnectionControl(box, row); } function appendLaneConnectionControl(box, row) { const fromIndex = row.from_lane_id.split(":").at(-1); const toIndex = row.to_lane_id.split(":").at(-1); const input = document.createElement("input"); const label = document.createElement("label"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); if (selected) { renderConnections(selected); renderMovementSummary(selected); draw(); } }; label.append(input, ` 第 ${fromIndex} 车道 → 第 ${toIndex} 车道`); box.append(label); } function renderAdditionalConnections(road, endpoint) { const box = document.querySelector("#connections"); const existing = new Set(state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint.id).map((connection) => connection.toEndpointId)); const candidates = state.compiled.model.endpoints.filter((item) => item.side === "start" && item.roadId !== road.id && !existing.has(item.id) && endpointDistanceMeters(endpoint, item) <= 35); box.innerHTML = ""; if (!candidates.length) { box.textContent = "附近 35 米内没有可新增的驶出道路。请检查 OSM 路口节点,或选择另一条道路。"; return; } const intro = document.createElement("p"); intro.textContent = "选择要新增允许驶入的道路:"; box.append(intro); for (const targetEndpoint of candidates) { const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint.roadId); const button = document.createElement("button"); button.type = "button"; button.textContent = `${turnName(road, target)}:${roadLabel(target)}(相距 ${endpointDistanceMeters(endpoint, targetEndpoint).toFixed(1)} 米)`; button.onclick = () => { stageConnection({ id: `manual:${endpoint.id}:${targetEndpoint.id}`, fromEndpointId: endpoint.id, toEndpointId: targetEndpoint.id }, true); renderConnections(road); }; box.append(button); } } function turnName(from, to) { const a = heading(from.centerline.at(-2), from.centerline.at(-1)); const b = heading(to.centerline[0], to.centerline[1]); const delta = ((b - a + 540) % 360) - 180; if (Math.abs(delta) >= 150) return "掉头"; if (Math.abs(delta) <= 30) return "直行"; return delta > 0 ? "右转" : "左转"; } function heading(a, b) { return Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; } function endpointDistanceMeters(a, b) { const dx = (a.coordinate[0] - b.coordinate[0]) * 111320 * Math.cos(a.coordinate[1] * Math.PI / 180); const dy = (a.coordinate[1] - b.coordinate[1]) * 111320; return Math.hypot(dx, dy); } function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); message("有未保存修改"); } function stageLaneConnection(connector, enabled) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId: connector.from_lane_id, toLaneId: connector.to_lane_id, enabled }); message("有未保存修改"); } function effectiveLaneEnabled(connector) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; } function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; } function effectiveConnectorEnabled(connector) { const connection = state.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); } function pointToSegmentDistance(point, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lengthSquared = dx * dx + dy * dy; const t = lengthSquared ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSquared)) : 0; return Math.hypot(point[0] - (a[0] + t * dx), point[1] - (a[1] + t * dy)); } function pickRoad(point) { let best = null; let distance = Infinity; for (const road of state.compiled.model.roads) for (let index = 1; index < road.centerline.length; index += 1) { const candidate = pointToSegmentDistance(point, coord(road.centerline[index - 1]), coord(road.centerline[index])); if (candidate < distance) { distance = candidate; best = road; } } return distance <= 18 * devicePixelRatio ? best : null; } canvas.onclick = (event) => { const rect = canvas.getBoundingClientRect(); select(pickRoad([(event.clientX - rect.left) * devicePixelRatio, (event.clientY - rect.top) * devicePixelRatio])); }; form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selected.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selected.id, widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }); message("有未保存修改"); }; saveButton.onclick = async () => { const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const data = await response.json(); if (!data.ok) return message(data.error); state.overrides = data.overrides; staged = []; message("已保存,点击“保存并重新生成”生效"); }; compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; setup(); message("已按保存的修改重新生成"); }; function focusDiagnostic(diagnostic) { state.focusedDiagnostic = diagnostic; const road = state.compiled.model.roads.find((item) => item.id === diagnostic.subjectId); if (road) { select(road); message(diagnostic.message); } else { draw(); message(diagnostic.message); } } function showInIssueList(diagnostic) { return diagnostic.severity === "error" || diagnostic.rule !== "ordinary-junction-surface"; } function setup() { areaLabel.textContent = state.areaId; diagnostics.innerHTML = ""; const issues = state.compiled.diagnostics.filter(showInIssueList); if (!issues.length) diagnostics.innerHTML = "
  • 没有需要人工检查的问题。
  • "; for (const diagnostic of issues) { const item = document.createElement("li"); const button = document.createElement("button"); button.className = diagnostic.severity === "error" ? "error" : ""; button.textContent = diagnostic.message; button.onclick = () => focusDiagnostic(diagnostic); item.append(button); diagnostics.append(item); } setBounds(); resize(); select(null); } fetch("/api/state").then((response) => response.json()).then((value) => { state = value; setup(); message(`${state.compiled.model.roads.length} 条方向道路,${state.compiled.diagnostics.length} 个待检查项`); }).catch((error) => message(error.message)); window.onresize = resize;