From 7786aa2675aa8ebb147cbc690138c5af54762b99 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Wed, 5 Aug 2026 17:32:36 +0800 Subject: [PATCH] feat(javaTest): render full-map route comparison --- .../main/resources/visualizer/assets/app.css | 33 +- .../main/resources/visualizer/assets/app.js | 411 ++++++++++++++++-- .../src/main/resources/visualizer/index.html | 19 +- ...istoricalDispatchVisualizerServerTest.java | 11 + 4 files changed, 431 insertions(+), 43 deletions(-) diff --git a/javaTest/src/main/resources/visualizer/assets/app.css b/javaTest/src/main/resources/visualizer/assets/app.css index e8a4401..b9ec42b 100644 --- a/javaTest/src/main/resources/visualizer/assets/app.css +++ b/javaTest/src/main/resources/visualizer/assets/app.css @@ -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; } diff --git a/javaTest/src/main/resources/visualizer/assets/app.js b/javaTest/src/main/resources/visualizer/assets/app.js index 55d8160..f264e52 100644 --- a/javaTest/src/main/resources/visualizer/assets/app.js +++ b/javaTest/src/main/resources/visualizer/assets/app.js @@ -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; - var ids = [].concat(task.plannedPath || [], task.historicalPath || []); - eventsForTask(currentResult(), task.simulationTaskId).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]; + if (!state.bootstrap) return; + var pathFloors = new Set(); + var vehicleFloor = null; + if (task) { + var ids = [].concat(task.plannedPath || [], task.historicalPath || []); + var events = eventsForTask(currentResult(), task.simulationTaskId); + events.forEach(function (event) { + if (event.fromSite) ids.push(event.fromSite); + if (event.toSite) ids.push(event.toSite); + }); + 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); } diff --git a/javaTest/src/main/resources/visualizer/index.html b/javaTest/src/main/resources/visualizer/index.html index f6fc128..2b0cbcf 100644 --- a/javaTest/src/main/resources/visualizer/index.html +++ b/javaTest/src/main/resources/visualizer/index.html @@ -114,15 +114,26 @@

算法规划路径

+
+ + +
+
+ + + +
+ 完整地图 接近路径 - A → B 规划 + 共同路段 + 仅新规划 + 仅历史 已通过 - 历史参考 HOLD
@@ -133,6 +144,8 @@ + +
等待调度结果 @@ -159,6 +172,8 @@
完成时间
--
HOLD / 重试
--
PLC 尝试
--
+
路径边差
共同 -- / 新增 -- / 历史 --
+
历史无效段
--
调度中心 plannedPath diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java index a20f593..ae54cae 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java @@ -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"));