feat(javaTest): render full-map route comparison

This commit is contained in:
zgw
2026-08-05 17:32:36 +08:00
parent 9946881e3a
commit 7786aa2675
4 changed files with 431 additions and 43 deletions

View File

@@ -58,7 +58,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
border-bottom: 1px solid var(--color-line);
}
.brand-block, .header-status, .button-row, .playback-buttons, .route-tools,
.brand-block, .header-status, .button-row, .playback-buttons, .route-tools, .map-actions,
.history-actions, .timeline-controls, .table-toolbar, .inline-field, .toggle-field {
display: flex;
align-items: center;
@@ -240,7 +240,14 @@ h3 { font-size: 14px; line-height: 1.3; }
border-bottom: 1px solid var(--color-line);
}
.section-kicker { display: block; margin-bottom: 3px; color: var(--color-muted); font-size: 10px; font-weight: 750; text-transform: uppercase; }
.route-tools { gap: 14px; }
.route-tools { gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.view-mode { display: inline-flex; height: 32px; border: 1px solid var(--color-line-strong); border-radius: var(--radius); overflow: hidden; }
.view-mode-button { min-width: 48px; padding: 0 9px; color: var(--color-muted); background: var(--color-surface); border: 0; border-right: 1px solid var(--color-line-strong); font-size: 12px; font-weight: 700; }
.view-mode-button:last-child { border-right: 0; }
.view-mode-button:hover { color: var(--color-ink); background: var(--color-surface-muted); }
.view-mode-button.is-active { color: #fff; background: var(--color-green); }
.map-actions { gap: 4px; }
.icon-button.compact { width: 32px; height: 32px; font-size: 16px; }
.inline-field { gap: 7px; }
.inline-field select { width: auto; min-width: 74px; height: 32px; }
.toggle-field { gap: 6px; }
@@ -259,22 +266,31 @@ h3 { font-size: 14px; line-height: 1.3; }
}
.route-legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend-line { display: inline-block; width: 22px; height: 0; border-top: 3px solid; }
.legend-line.map-base { border-color: #bfc9c5; border-top-width: 1px; }
.legend-line.approach { border-color: var(--color-approach-path); border-top-style: dashed; }
.legend-line.service { border-color: var(--color-service-path); }
.legend-line.shared { height: 6px; border-top: 6px solid var(--color-service-path); border-bottom: 2px dotted var(--color-historical-path); }
.legend-line.planned-only { border-color: var(--color-service-path); border-top-width: 5px; }
.legend-line.historical-only { border-color: var(--color-historical-path); border-top-style: dotted; }
.legend-line.traversed { border-color: var(--color-traversed-path); border-top-width: 5px; }
.legend-line.historical { border-color: var(--color-historical-path); border-top-style: dotted; }
.legend-dot { width: 10px; height: 10px; border: 2px solid; border-radius: 50%; }
.legend-dot.hold { border-color: var(--color-amber); }
.route-canvas-wrap { position: relative; min-height: 425px; height: calc(100% - 92px); overflow: hidden; }
#route-map { display: block; width: 100%; height: 100%; min-height: 425px; background: #fbfcfc; }
#route-map { display: block; width: 100%; height: 100%; min-height: 425px; background: #fbfcfc; cursor: grab; touch-action: none; user-select: none; }
#route-map.is-dragging { cursor: grabbing; }
.map-background { fill: url(#map-grid); }
.grid-line { fill: none; stroke: #e7ecea; stroke-width: 1; }
.route-segment { fill: none; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.route-segment.map-edge { stroke: #c5cfcb; stroke-width: 1.25; opacity: .9; }
.route-segment.approach { stroke: var(--color-approach-path); stroke-width: 4; stroke-dasharray: 9 8; }
.route-segment.service { stroke: var(--color-service-path); stroke-width: 6; }
.route-segment.historical { stroke: var(--color-historical-path); stroke-width: 3; stroke-dasharray: 3 7; opacity: .82; }
.route-segment.planned-only { stroke: var(--color-service-path); stroke-width: 6; }
.route-segment.historical-only { stroke: var(--color-historical-path); stroke-width: 4; stroke-dasharray: 3 7; }
.route-segment.shared-base { stroke: var(--color-service-path); stroke-width: 7; }
.route-segment.shared-overlay { stroke: var(--color-historical-path); stroke-width: 3; stroke-dasharray: 2 8; }
.route-segment.traversed { stroke: var(--color-traversed-path); stroke-width: 8; }
.map-site { fill: #d8dfdc; stroke: #b6c1bd; stroke-width: 1; vector-effect: non-scaling-stroke; }
.map-node { fill: #fff; stroke: #7d8a86; stroke-width: 2; vector-effect: non-scaling-stroke; }
.map-node.passed { fill: var(--color-blue-soft); stroke: var(--color-blue); }
.map-node.start { fill: var(--color-green-soft); stroke: var(--color-green); stroke-width: 4; }
@@ -297,6 +313,9 @@ h3 { font-size: 14px; line-height: 1.3; }
background: rgba(251, 252, 252, .94);
}
.empty-state strong, .loading-state strong { color: var(--color-ink); }
.empty-state.is-map-hint { inset: auto auto 12px 12px; align-items: flex-start; min-width: 190px; padding: 9px 11px; background: rgba(255, 255, 255, .92); border: 1px solid var(--color-line); border-radius: var(--radius); box-shadow: 0 4px 14px rgba(22, 33, 31, .08); pointer-events: none; }
.empty-state.is-map-hint strong { font-size: 12px; }
.empty-state.is-map-hint span { font-size: 11px; }
.activity-indicator { width: 26px; height: 26px; border: 3px solid #cbd6d2; border-top-color: var(--color-green); border-radius: 50%; animation: rotate 800ms linear infinite; }
@keyframes rotate { to { transform: rotate(360deg); } }
@@ -390,7 +409,9 @@ h3 { font-size: 14px; line-height: 1.3; }
.button-row { flex-wrap: wrap; justify-content: flex-end; }
.kpi-strip { grid-template-columns: repeat(2, 1fr); }
.section-bar { align-items: flex-start; padding: 10px 12px; }
.route-tools { display: grid; gap: 5px; justify-items: end; }
.route-tools { display: grid; grid-template-columns: auto auto; gap: 5px 7px; justify-items: end; }
.view-mode { grid-column: 1; }
.map-actions { grid-column: 2; }
.route-legend { gap: 9px; padding: 6px 12px; }
.route-canvas-wrap, #route-map { min-height: 340px; }
.evidence-list > div { grid-template-columns: 88px 1fr; padding: 9px 12px; }

View File

@@ -12,7 +12,10 @@
tickIndex: 0,
timer: null,
busy: false,
selectedFloor: null
selectedFloor: null,
routeView: { mode: "map", x: 0, y: 0, width: 1000, height: 560 },
routeDrag: null,
projectors: {}
};
var elements = {};
@@ -33,10 +36,12 @@
"swap-sites", "run-point", "run-history", "history-run-state", "history-kpis",
"task-search", "task-status-filter", "task-filter-count", "history-task-body",
"task-workspace", "route-map", "route-empty", "loading-state", "loading-label",
"floor-select", "show-historical", "inspector-task-id", "task-status",
"floor-select", "show-historical", "map-fit", "path-fit", "zoom-in", "zoom-out",
"reset-view", "inspector-task-id", "task-status",
"evidence-endpoints", "evidence-vehicle", "evidence-history-vehicle",
"evidence-assigned-time", "evidence-waiting", "evidence-completion",
"evidence-hold-retry", "evidence-plc", "planned-path-output", "reason-output",
"evidence-hold-retry", "evidence-plc", "route-difference",
"invalid-history-segments", "planned-path-output", "reason-output",
"timeline-panel", "step-back", "play-timeline", "pause-timeline", "step-forward",
"tick-readout", "timeline-slider", "playback-speed", "event-type-filter",
"event-count", "event-table-body", "safety-view", "safety-metrics",
@@ -57,8 +62,18 @@
elements["floor-select"].addEventListener("change", function () {
state.selectedFloor = Number(elements["floor-select"].value);
renderRoute();
fitCurrentViewMode();
});
elements["show-historical"].addEventListener("change", renderRoute);
elements["map-fit"].addEventListener("click", function () { setViewMode("map"); });
elements["path-fit"].addEventListener("click", function () { setViewMode("path"); });
elements["zoom-in"].addEventListener("click", function () { zoomRoute(0.8); });
elements["zoom-out"].addEventListener("click", function () { zoomRoute(1.25); });
elements["reset-view"].addEventListener("click", resetRouteView);
elements["route-map"].addEventListener("wheel", handleRouteWheel, { passive: false });
elements["route-map"].addEventListener("pointerdown", beginRouteDrag);
window.addEventListener("pointermove", continueRouteDrag);
window.addEventListener("pointerup", endRouteDrag);
elements["timeline-slider"].addEventListener("input", function () {
stopPlayback();
setTickIndex(Number(elements["timeline-slider"].value));
@@ -84,6 +99,9 @@
elements["map-version"].textContent = "地图 " + shortHash(state.bootstrap.mapSha256);
populateSites();
chooseDefaultEndpoints();
populateFloors();
resetRouteView();
renderRoute();
setServiceStatus("本地服务正常", "pass");
} catch (error) {
setServiceStatus("服务不可用", "fail");
@@ -116,6 +134,7 @@
});
elements["source-site"].value = source.siteId;
elements["target-site"].value = (sameFloor[0] || state.bootstrap.sites[1]).siteId;
if (state.selectedFloor === null) state.selectedFloor = source.floor;
}
async function runPointSimulation(event) {
@@ -201,6 +220,11 @@
function selectResult(result, taskId) {
state.selectedTaskId = taskId;
state.tickIndex = 0;
var selected = result.tasks.find(function (task) {
return task.simulationTaskId === taskId;
});
var startSite = selected ? siteById(selected.physicalStartSite) : null;
if (startSite) state.selectedFloor = startSite.floor;
var taskEvents = eventsForTask(result, taskId);
state.eventTicks = unique(taskEvents.map(function (event) { return Number(event.tick); }))
.sort(function (left, right) { return left - right; });
@@ -211,6 +235,7 @@
populateEventTypes(taskEvents);
populateFloors();
renderSelectedTask();
fitCurrentViewMode();
updateAcceptance(result);
}
@@ -231,7 +256,7 @@
function renderSelectedTask() {
var task = selectedTask();
if (!task) {
showEmpty("等待调度结果", "路径证据将在本区域显示");
renderRoute();
resetInspector();
return;
}
@@ -252,6 +277,12 @@
setText("evidence-completion", formatDuration(task.simulationDurationMillis));
setText("evidence-hold-retry", task.holdCount + " / " + task.retryCount);
setText("evidence-plc", String(task.protocolAttempts));
var comparison = task.pathComparison || emptyPathComparison();
setText("route-difference", "共同 " + comparison.sharedEdgeCount
+ " / 新增 " + comparison.plannedOnlyEdgeCount
+ " / 历史 " + comparison.historicalOnlyEdgeCount);
setText("invalid-history-segments", formatInvalidSegments(
comparison.invalidHistoricalSegments));
elements["planned-path-output"].textContent = task.plannedPath.length
? task.plannedPath.join(" → ") : "无规划路径";
elements["reason-output"].textContent = task.reason || task.status;
@@ -259,20 +290,36 @@
function populateFloors() {
var task = selectedTask();
if (!task || !state.bootstrap) return;
if (!state.bootstrap) return;
var pathFloors = new Set();
var vehicleFloor = null;
if (task) {
var ids = [].concat(task.plannedPath || [], task.historicalPath || []);
eventsForTask(currentResult(), task.simulationTaskId).forEach(function (event) {
var events = eventsForTask(currentResult(), task.simulationTaskId);
events.forEach(function (event) {
if (event.fromSite) ids.push(event.fromSite);
if (event.toSite) ids.push(event.toSite);
});
var floors = unique(ids.map(siteById).filter(Boolean).map(function (site) { return site.floor; }));
if (!floors.length) floors = state.bootstrap.floors.slice();
state.selectedFloor = floors.indexOf(state.selectedFloor) >= 0 ? state.selectedFloor : floors[0];
ids.map(siteById).filter(Boolean).forEach(function (site) {
pathFloors.add(site.floor);
});
var moves = events.filter(function (event) {
return event.eventType === "MOVE" && Number(event.tick) <= tickValue();
});
var vehicleSite = siteById(currentVehicleSite(task, moves));
if (vehicleSite) vehicleFloor = vehicleSite.floor;
}
var floors = state.bootstrap.floors.slice();
state.selectedFloor = floors.indexOf(state.selectedFloor) >= 0
? state.selectedFloor : floors[0];
var fragment = document.createDocumentFragment();
floors.forEach(function (floor) {
var option = document.createElement("option");
option.value = String(floor);
option.textContent = floor + "F";
var flags = [];
if (pathFloors.has(floor)) flags.push("路径");
if (floor === vehicleFloor) flags.push("车辆");
option.textContent = floor + "F" + (flags.length ? " · " + flags.join(" · ") : "");
option.selected = floor === state.selectedFloor;
fragment.appendChild(option);
});
@@ -280,13 +327,19 @@
}
function renderRoute() {
var task = selectedTask();
if (!task || !state.bootstrap) return;
if (!state.bootstrap || state.selectedFloor === null) return;
var svg = elements["route-map"];
var old = svg.querySelector("#dynamic-route-layer");
if (old) old.remove();
var layer = svgElement("g", { id: "dynamic-route-layer" });
svg.appendChild(layer);
var project = projectorForFloor(state.selectedFloor);
renderMapBackground(state.selectedFloor, project);
var layer = svg.querySelector("#dynamic-route-layer");
layer.replaceChildren();
var task = selectedTask();
if (!task) {
showMapHint("等待调度结果", "当前楼层完整地图已加载");
applyRouteView();
return;
}
var events = eventsForTask(currentResult(), task.simulationTaskId);
var moves = events.filter(function (event) { return event.eventType === "MOVE"; });
@@ -305,15 +358,14 @@
return site && site.floor === state.selectedFloor;
});
if (!sites.length) {
showEmpty("当前楼层没有路径节点", "请选择包含任务路径的楼层");
showMapHint("当前楼层无任务路径", "完整地图仍可检查,可切换带“路径”的楼层");
applyRouteView();
return;
}
elements["route-empty"].hidden = true;
var project = projector(sites);
hideMapHint();
drawPath(layer, historical, "historical", project);
drawPath(layer, approach, "approach", project);
drawPath(layer, task.plannedPath, "service", project);
drawRouteComparison(layer, task, project);
drawMoveSegments(layer, traversedMoves, "traversed", project);
var passedIds = new Set();
@@ -324,14 +376,15 @@
var lastEvent = lastEventAt(events, currentTick);
var holdSite = lastEvent && isHold(lastEvent.eventType)
? (lastEvent.toSite || lastEvent.fromSite) : null;
var labelEvery = sites.length <= 12 ? 1 : sites.length <= 28 ? 3 : 5;
sites.forEach(function (site, index) {
var comparisonNodes = comparisonNodeIds(task,
elements["show-historical"].checked);
sites.forEach(function (site) {
drawNode(layer, site, project, {
passed: passedIds.has(site.siteId),
start: site.siteId === task.physicalStartSite,
target: site.siteId === task.physicalEndSite,
hold: site.siteId === holdSite,
label: index % labelEvery === 0 || site.siteId === currentSite
label: comparisonNodes.has(site.siteId) || site.siteId === currentSite
|| site.siteId === task.physicalStartSite || site.siteId === task.physicalEndSite
});
});
@@ -339,9 +392,15 @@
if (vehicleSite && vehicleSite.floor === state.selectedFloor) {
drawVehicle(layer, vehicleSite, task.simulatedVehicleId, project);
}
applyRouteView();
}
function projector(sites) {
/** 使用当前楼层全部站点建立稳定投影,切换任务和 tick 时地图位置不会跳动。 */
function projectorForFloor(floor) {
if (state.projectors[floor]) return state.projectors[floor];
var sites = state.bootstrap.sites.filter(function (site) {
return site.floor === floor;
});
var xs = sites.map(function (site) { return Number(site.x); });
var ys = sites.map(function (site) { return Number(site.y); });
var minX = Math.min.apply(Math, xs);
@@ -350,18 +409,126 @@
var maxY = Math.max.apply(Math, ys);
var width = Math.max(1, maxX - minX);
var height = Math.max(1, maxY - minY);
var scale = Math.min(860 / width, 430 / height);
var scale = Math.min(880 / width, 440 / height);
var drawWidth = width * scale;
var drawHeight = height * scale;
var offsetX = (1000 - drawWidth) / 2;
var offsetY = (560 - drawHeight) / 2;
return function (site) {
if (!site || site.floor !== state.selectedFloor) return null;
state.projectors[floor] = function (site) {
if (!site || site.floor !== floor) return null;
return {
x: offsetX + (Number(site.x) - minX) * scale,
y: 560 - offsetY - (Number(site.y) - minY) * scale
};
};
return state.projectors[floor];
}
/** 首次查看楼层时绘制并缓存权威道路与站点;时间线推进只更新动态层。 */
function renderMapBackground(floor, project) {
var cache = elements["route-map"].querySelector("#map-cache-layer");
var layerId = "map-floor-" + floor;
var layer = cache.querySelector("#" + layerId);
if (!layer) {
layer = svgElement("g", { id: layerId, class: "map-floor-layer" });
state.bootstrap.edges.filter(function (edge) {
return Number(edge.floor) === floor;
}).forEach(function (edge) {
drawSegment(layer, edge.fromSite, edge.toSite, "map-edge", project);
});
state.bootstrap.sites.filter(function (site) {
return site.floor === floor;
}).forEach(function (site) {
drawBackgroundNode(layer, site, project);
});
cache.appendChild(layer);
}
cache.querySelectorAll(".map-floor-layer").forEach(function (candidate) {
if (candidate.id === layerId) candidate.removeAttribute("hidden");
else candidate.setAttribute("hidden", "hidden");
});
}
/** 绘制完整地图中的低对比度站点;编号和坐标通过原生 SVG tooltip 查看。 */
function drawBackgroundNode(layer, site, project) {
var point = project(site);
if (!point) return;
var node = svgElement("circle", {
cx: point.x, cy: point.y, r: 2.4, class: "map-site"
});
var title = svgElement("title");
title.textContent = site.siteId + " · " + site.floor + "F · ("
+ site.x + ", " + site.y + ")";
node.appendChild(title);
layer.appendChild(node);
}
/** 按后端给出的边集合差异叠加共同、仅新规划和仅历史路线。 */
function drawRouteComparison(layer, task, project) {
var comparison = task.pathComparison;
if (!comparison) {
if (elements["show-historical"].checked) {
drawPath(layer, task.historicalPath || [], "historical", project);
}
drawPath(layer, task.plannedPath || [], "service", project);
return;
}
drawEvidenceEdges(layer, comparison.sharedEdges,
"shared-base planned-only", project);
drawEvidenceEdges(layer, comparison.plannedOnlyEdges, "planned-only", project);
if (elements["show-historical"].checked) {
drawEvidenceEdges(layer, comparison.sharedEdges,
"shared-overlay historical-only", project);
drawEvidenceEdges(layer, comparison.historicalOnlyEdges,
"historical-only", project);
}
}
/** 将证据 DTO 中的端点直接画成线,不在浏览器中重新判断或规划邻接关系。 */
function drawEvidenceEdges(layer, edges, className, project) {
(edges || []).forEach(function (edge) {
drawSegment(layer, edge.fromSite, edge.toSite, className, project);
});
}
/** 返回新旧路线分叉涉及的站点,用于只标注真正需要人工比较的位置。 */
function comparisonNodeIds(task, includeHistorical) {
var comparison = task.pathComparison || emptyPathComparison();
var incidentTypes = new Map();
// 记录每个站点实际连接到哪类证据边,避免把一整段路线的所有中间点都当成分叉点。
function markIncident(edges, type) {
(edges || []).forEach(function (edge) {
[edge.fromSite, edge.toSite].forEach(function (siteId) {
if (!incidentTypes.has(siteId)) incidentTypes.set(siteId, new Set());
incidentTypes.get(siteId).add(type);
});
});
}
markIncident(comparison.sharedEdges, "shared");
markIncident(comparison.plannedOnlyEdges, "planned");
if (includeHistorical) markIncident(comparison.historicalOnlyEdges, "historical");
var boundaries = [];
incidentTypes.forEach(function (types, siteId) {
// 两条专属路线在此相遇,或路线从共同路段切出/汇回时,才属于人工审计需要的边界。
var joinsExclusiveRoutes = types.has("planned") && types.has("historical");
var leavesSharedRoute = types.has("shared")
&& (types.has("planned") || types.has("historical"));
if (joinsExclusiveRoutes || leavesSharedRoute) boundaries.push(siteId);
});
return new Set(boundaries);
}
function showMapHint(title, detail) {
showEmpty(title, detail);
elements["route-empty"].classList.add("is-map-hint");
}
function hideMapHint() {
elements["route-empty"].hidden = true;
elements["route-empty"].classList.remove("is-map-hint");
}
function drawPath(layer, ids, className, project) {
@@ -406,11 +573,19 @@
circle.appendChild(title);
layer.appendChild(circle);
if (flags.label) {
var label = svgElement("text", { x: point.x + 11, y: point.y - 10, class: "node-label" });
// 靠近地图右边界的业务标签改向左展开,路径聚焦时站点号和坐标不会被 SVG 裁掉。
var labelOnLeft = point.x > 900;
var labelX = point.x + (labelOnLeft ? -11 : 11);
var textAnchor = labelOnLeft ? "end" : "start";
var label = svgElement("text", {
x: labelX, y: point.y - 10, "text-anchor": textAnchor, class: "node-label"
});
label.textContent = site.siteId;
layer.appendChild(label);
if (flags.start || flags.target) {
var coordinate = svgElement("text", { x: point.x + 11, y: point.y + 7, class: "node-coordinate" });
var coordinate = svgElement("text", {
x: labelX, y: point.y + 7, "text-anchor": textAnchor, class: "node-coordinate"
});
coordinate.textContent = "(" + site.x + ", " + site.y + ")";
layer.appendChild(coordinate);
}
@@ -432,10 +607,158 @@
layer.appendChild(marker);
}
/** 汇总当前任务的全部证据节点,供“路径”视口计算包围盒。 */
function selectedRouteSiteIds() {
var task = selectedTask();
if (!task) return [];
var ids = [].concat(task.plannedPath || [], task.historicalPath || [],
[task.physicalStartSite, task.physicalEndSite]);
eventsForTask(currentResult(), task.simulationTaskId).forEach(function (event) {
if (event.fromSite) ids.push(event.fromSite);
if (event.toSite) ids.push(event.toSite);
});
return unique(ids);
}
/** 切换完整地图或路径聚焦视口,保持路径与底图使用同一坐标系。 */
function setViewMode(mode) {
if (mode === "path" && selectedTask()) fitPathView();
else resetRouteView();
state.routeView.mode = mode === "path" && selectedTask() ? "path" : "map";
updateViewModeButtons();
applyRouteView();
}
/** 任务或楼层改变时按当前模式重新适配;自由缩放视口回到完整地图。 */
function fitCurrentViewMode() {
if (state.routeView.mode === "path" && selectedTask()) fitPathView();
else resetRouteView();
}
/** 恢复当前楼层完整地图边界。 */
function resetRouteView() {
state.routeView = { mode: "map", x: 0, y: 0, width: 1000, height: 560 };
updateViewModeButtons();
applyRouteView();
}
/** 适配当前楼层上与任务有关的全部节点,并保留周围道路上下文。 */
function fitPathView() {
var project = projectorForFloor(state.selectedFloor);
var points = selectedRouteSiteIds().map(siteById).filter(Boolean)
.map(project).filter(Boolean);
if (!points.length) {
resetRouteView();
return;
}
var xs = points.map(function (point) { return point.x; });
var ys = points.map(function (point) { return point.y; });
var minX = Math.min.apply(Math, xs);
var maxX = Math.max.apply(Math, xs);
var minY = Math.min.apply(Math, ys);
var maxY = Math.max.apply(Math, ys);
var centerX = (minX + maxX) / 2;
var centerY = (minY + maxY) / 2;
// 两侧至少保留 64 个 SVG 单位,覆盖 6 位站点号和起终点坐标的实际文字宽度。
var width = Math.max(180, maxX - minX + 128);
var height = Math.max(101, maxY - minY + 128);
var ratio = 1000 / 560;
if (width / height > ratio) height = width / ratio;
else width = height * ratio;
width = Math.min(1000, width);
height = Math.min(560, height);
state.routeView = {
mode: "path",
x: centerX - width / 2,
y: centerY - height / 2,
width: width,
height: height
};
clampRouteView();
updateViewModeButtons();
applyRouteView();
}
/** 将内存中的视口写入 SVG;tick 重绘不会调用任何适配逻辑。 */
function applyRouteView() {
var view = state.routeView;
elements["route-map"].setAttribute("viewBox",
[view.x, view.y, view.width, view.height].join(" "));
}
/** 围绕鼠标或画布中心缩放,并限制在完整地图坐标范围内。 */
function zoomRoute(factor, anchorX, anchorY) {
var view = state.routeView;
var nextWidth = Math.max(140, Math.min(1000, view.width * factor));
var nextHeight = nextWidth * 560 / 1000;
var xRatio = anchorX === undefined ? 0.5 : anchorX;
var yRatio = anchorY === undefined ? 0.5 : anchorY;
view.x += (view.width - nextWidth) * xRatio;
view.y += (view.height - nextHeight) * yRatio;
view.width = nextWidth;
view.height = nextHeight;
view.mode = "custom";
clampRouteView();
updateViewModeButtons();
applyRouteView();
}
function handleRouteWheel(event) {
event.preventDefault();
var bounds = elements["route-map"].getBoundingClientRect();
zoomRoute(event.deltaY > 0 ? 1.12 : 0.89,
(event.clientX - bounds.left) / bounds.width,
(event.clientY - bounds.top) / bounds.height);
}
function beginRouteDrag(event) {
if (event.button !== 0) return;
event.preventDefault();
state.routeDrag = { x: event.clientX, y: event.clientY };
state.routeView.mode = "custom";
updateViewModeButtons();
elements["route-map"].classList.add("is-dragging");
elements["route-map"].setPointerCapture(event.pointerId);
}
function continueRouteDrag(event) {
if (!state.routeDrag) return;
var bounds = elements["route-map"].getBoundingClientRect();
state.routeView.x -= (event.clientX - state.routeDrag.x)
* state.routeView.width / bounds.width;
state.routeView.y -= (event.clientY - state.routeDrag.y)
* state.routeView.height / bounds.height;
state.routeDrag = { x: event.clientX, y: event.clientY };
clampRouteView();
applyRouteView();
}
function endRouteDrag(event) {
if (!state.routeDrag) return;
state.routeDrag = null;
elements["route-map"].classList.remove("is-dragging");
if (event && elements["route-map"].hasPointerCapture(event.pointerId)) {
elements["route-map"].releasePointerCapture(event.pointerId);
}
}
/** 避免缩放或拖拽后视口离开 1000x560 的完整地图画布。 */
function clampRouteView() {
var view = state.routeView;
view.x = Math.max(0, Math.min(1000 - view.width, view.x));
view.y = Math.max(0, Math.min(560 - view.height, view.y));
}
function updateViewModeButtons() {
elements["map-fit"].classList.toggle("is-active", state.routeView.mode === "map");
elements["path-fit"].classList.toggle("is-active", state.routeView.mode === "path");
}
function renderTimeline() {
elements["timeline-slider"].value = String(state.tickIndex);
elements["tick-readout"].textContent = "tick " + tickValue();
renderRoute();
populateFloors();
renderEventTable();
}
@@ -746,6 +1069,7 @@
}
function showEmpty(title, detail) {
elements["route-empty"].classList.remove("is-map-hint");
elements["route-empty"].hidden = false;
var strong = elements["route-empty"].querySelector("strong");
var span = elements["route-empty"].querySelector("span");
@@ -756,7 +1080,8 @@
function resetInspector() {
["inspector-task-id", "evidence-endpoints", "evidence-vehicle", "evidence-history-vehicle",
"evidence-assigned-time", "evidence-waiting", "evidence-completion",
"evidence-hold-retry", "evidence-plc", "planned-path-output", "reason-output"]
"evidence-hold-retry", "evidence-plc", "route-difference",
"invalid-history-segments", "planned-path-output", "reason-output"]
.forEach(function (id) { setText(id, "--"); });
setTaskStatus("--");
}
@@ -835,6 +1160,22 @@
return value === null || value === undefined ? "--" : "T+" + formatDuration(value);
}
function emptyPathComparison() {
return {
sharedEdges: [], plannedOnlyEdges: [], historicalOnlyEdges: [],
invalidHistoricalSegments: [], sharedEdgeCount: 0,
plannedOnlyEdgeCount: 0, historicalOnlyEdgeCount: 0
};
}
function formatInvalidSegments(segments) {
if (!segments || !segments.length) return "0";
return segments.length + " 段:" + segments.map(function (segment) {
return (segment.fromSite || "--") + " → " + (segment.toSite || "--")
+ " (" + segment.reason + ")";
}).join(";");
}
function setText(id, value) { elements[id].textContent = value; }
function shortHash(value) { return value ? value.slice(0, 10) : "--"; }
function manhattan(left, right) { return Math.abs(left.x - right.x) + Math.abs(left.y - right.y); }

View File

@@ -114,15 +114,26 @@
<h2 id="route-title">算法规划路径</h2>
</div>
<div class="route-tools">
<div class="view-mode" role="group" aria-label="地图视口">
<button id="map-fit" type="button" class="view-mode-button is-active">全图</button>
<button id="path-fit" type="button" class="view-mode-button">路径</button>
</div>
<div class="map-actions" role="group" aria-label="地图缩放">
<button id="zoom-out" type="button" class="icon-button compact" title="缩小地图" aria-label="缩小地图">−</button>
<button id="zoom-in" type="button" class="icon-button compact" title="放大地图" aria-label="放大地图">+</button>
<button id="reset-view" type="button" class="icon-button compact" title="复位为完整地图" aria-label="复位为完整地图">⌂</button>
</div>
<label class="inline-field"><span>楼层</span><select id="floor-select"></select></label>
<label class="toggle-field"><input id="show-historical" type="checkbox" checked><span>历史参考</span></label>
</div>
</div>
<div class="route-legend" aria-label="路径图例">
<span><i class="legend-line map-base"></i>完整地图</span>
<span><i class="legend-line approach"></i>接近路径</span>
<span><i class="legend-line service"></i>A → B 规划</span>
<span><i class="legend-line shared"></i>共同路段</span>
<span><i class="legend-line planned-only"></i>仅新规划</span>
<span><i class="legend-line historical-only"></i>仅历史</span>
<span><i class="legend-line traversed"></i>已通过</span>
<span><i class="legend-line historical"></i>历史参考</span>
<span><i class="legend-dot hold"></i>HOLD</span>
</div>
<div class="route-canvas-wrap">
@@ -133,6 +144,8 @@
</pattern>
</defs>
<rect width="1000" height="560" class="map-background"></rect>
<g id="map-cache-layer"></g>
<g id="dynamic-route-layer"></g>
</svg>
<div id="route-empty" class="empty-state">
<strong>等待调度结果</strong>
@@ -159,6 +172,8 @@
<div><dt>完成时间</dt><dd id="evidence-completion">--</dd></div>
<div><dt>HOLD / 重试</dt><dd id="evidence-hold-retry">--</dd></div>
<div><dt>PLC 尝试</dt><dd id="evidence-plc">--</dd></div>
<div><dt>路径边差</dt><dd id="route-difference">共同 -- / 新增 -- / 历史 --</dd></div>
<div><dt>历史无效段</dt><dd id="invalid-history-segments">--</dd></div>
</dl>
<div class="path-proof">
<span>调度中心 plannedPath</span>

View File

@@ -109,9 +109,20 @@ class HistoricalDispatchVisualizerServerTest {
assertTrue(page.body.contains("调度路径审计台"));
assertTrue(page.body.contains("id=\"route-map\""));
assertTrue(page.body.contains("id=\"timeline-slider\""));
assertTrue(page.body.contains("id=\"map-fit\""));
assertTrue(page.body.contains("id=\"path-fit\""));
assertTrue(page.body.contains("id=\"zoom-in\""));
assertTrue(page.body.contains("id=\"zoom-out\""));
assertTrue(page.body.contains("id=\"reset-view\""));
assertTrue(page.body.contains("id=\"route-difference\""));
assertTrue(page.body.contains("id=\"invalid-history-segments\""));
assertEquals(200, stylesheet.status);
assertEquals("text/css; charset=utf-8", stylesheet.contentType);
assertTrue(stylesheet.body.contains("--color-service-path"));
assertTrue(stylesheet.body.contains(".map-edge"));
assertTrue(stylesheet.body.contains(".planned-only"));
assertTrue(stylesheet.body.contains(".historical-only"));
assertTrue(stylesheet.body.contains(".shared-overlay"));
assertEquals(200, script.status);
assertEquals("application/javascript; charset=utf-8", script.contentType);
assertTrue(script.body.contains("/api/simulations/point-to-point"));