diff --git a/docs/superpowers/plans/2026-08-05-javaTest-full-map-route-comparison.md b/docs/superpowers/plans/2026-08-05-javaTest-full-map-route-comparison.md new file mode 100644 index 0000000..bcbe794 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-javaTest-full-map-route-comparison.md @@ -0,0 +1,777 @@ +# javaTest Full-Map Route Comparison Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在调度审计台中显示当前楼层完整权威地图,并在同一坐标系内清楚区分历史路径、新规划路径、共同路段和实际轨迹。 + +**Architecture:** 新增 `RouteTopologyEvidenceMapper`,只从 `GridMap` 导出规范化无向边并计算两条路径的边集合差;`SimulationVisualizationMapper` 将这些证据加入现有 bootstrap 和 task JSON。前端使用全楼层站点计算稳定投影,缓存楼层底图,动态叠加路径比较、tick 轨迹和车辆,并通过 SVG viewBox 实现全图/聚焦、缩放和拖拽。 + +**Tech Stack:** Java 8、JUnit 5、Jackson、Java `HttpServer`、原生 HTML/CSS/JavaScript、SVG、Playwright/Chrome 浏览器验收。 + +## Global Constraints + +- 服务继续只绑定 `127.0.0.1:18084`,不连接 SQL Server、真实 PLC 或 WMS。 +- 浏览器不得实现选车或路径规划;地图边只能来自 `GridMap.getNeighbors()`。 +- 不新增 Maven 或 Node 生产依赖,保持 Java 8 编译目标。 +- 默认显示当前楼层完整地图,并保留 1-8 楼切换。 +- 新规划为绿色、历史路径为橙色点线、已通过为蓝色、HOLD 为琥珀色。 +- 共同边必须同时保留绿色底线和橙色点线证据;关闭历史参考后仍显示完整新规划。 +- 完整地图不常驻显示全部站点编号;A、B、车辆、HOLD、分叉和悬停站点可读。 +- 保留现有 `pathChanged` 节点序列语义;新增边差只用于审计解释,不参与调度结果。 +- 保留原有未跟踪 `docs/*.html`,提交时只暂存本计划列出的文件。 + +--- + +## File Structure + +- Create `javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/RouteTopologyEvidenceMapper.java`: 导出完整地图边并计算路径边差。 +- Create `javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/RouteTopologyEvidenceMapperTest.java`: 隔离验证规范化、集合差和无效历史段。 +- Modify `javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java`: 把地图边和 `pathComparison` 组装进 JSON。 +- Modify `javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java`: 验证完整真实地图数据契约。 +- Modify `javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java`: 验证页面控件及 API 含完整地图证据。 +- Modify `javaTest/src/main/resources/visualizer/index.html`: 增加视口工具、差异图例和检查器字段。 +- Modify `javaTest/src/main/resources/visualizer/assets/app.css`: 完整地图、差异路径和视口交互样式。 +- Modify `javaTest/src/main/resources/visualizer/assets/app.js`: 完整楼层投影、背景缓存、差异叠加、缩放和平移。 +- Modify `javaTest/README.md`: 说明完整地图和路径对比的使用方法。 + +--- + +### Task 1: 权威地图边与路径差异证据模块 + +**Files:** +- Create: `javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/RouteTopologyEvidenceMapper.java` +- Create: `javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/RouteTopologyEvidenceMapperTest.java` + +**Interfaces:** +- Consumes: `GridMap.getSites()`、`GridMap.getNeighbors(String)`、历史与规划节点序列。 +- Produces: package-private `List> fullMapEdges(GridMap)` 和 `Map compare(GridMap,List,List)`。 + +- [ ] **Step 1: 写地图边与路径差异失败测试** + +测试构造同层站点 `A-B-C-D`、规划分支 `B-E-D`,并断言完整边唯一、共同边为 `A-B`、历史专属为 `B-C/C-D`、规划专属为 `B-E/D-E`: + +```java +@Test +void exportsCanonicalEdgesAndExplainsRouteDifferences() { + GridMap map = new GridMap(); + add(map, "A", 0, 0); add(map, "B", 1, 0); add(map, "C", 2, 0); + add(map, "D", 3, 0); add(map, "E", 2, 1); + connect(map, "A", "B"); connect(map, "B", "C"); connect(map, "C", "D"); + connect(map, "B", "E"); connect(map, "E", "D"); + + RouteTopologyEvidenceMapper mapper = new RouteTopologyEvidenceMapper(); + List> edges = mapper.fullMapEdges(map); + Map comparison = mapper.compare(map, + Arrays.asList("A", "B", "C", "D"), + Arrays.asList("A", "B", "E", "D")); + + assertEquals(5, edges.size()); + assertEquals(1, comparison.get("sharedEdgeCount")); + assertEquals(2, comparison.get("plannedOnlyEdgeCount")); + assertEquals(2, comparison.get("historicalOnlyEdgeCount")); + assertEquals(Collections.emptyList(), comparison.get("invalidHistoricalSegments")); +} + +@Test +void rejectsUnknownAndNonAdjacentHistoricalSegmentsWithoutInventingEdges() { + GridMap map = lineMap(); + Map comparison = new RouteTopologyEvidenceMapper().compare(map, + Arrays.asList("A", "UNKNOWN", "C"), Arrays.asList("A", "B", "C")); + + List> invalid = listOfMaps( + comparison.get("invalidHistoricalSegments")); + assertEquals(2, invalid.size()); + assertTrue(invalid.stream().allMatch(item -> "UNKNOWN_SITE".equals(item.get("reason")))); + assertEquals(2, comparison.get("plannedOnlyEdgeCount")); +} + +private static void add(GridMap map, String id, int x, int y) { + map.addSite(new Site(id, 1, x, y, "TEST")); +} + +private static void connect(GridMap map, String left, String right) { + map.connect(left, right); +} + +private static GridMap lineMap() { + GridMap map = new GridMap(); + add(map, "A", 0, 0); add(map, "B", 1, 0); add(map, "C", 2, 0); + connect(map, "A", "B"); connect(map, "B", "C"); + return map; +} + +@SuppressWarnings("unchecked") +private static List> listOfMaps(Object value) { + return (List>) value; +} +``` + +- [ ] **Step 2: 运行测试并确认 RED** + +Run: + +```powershell +mvn "-Dtest=RouteTopologyEvidenceMapperTest" test +``` + +Expected: test compilation fails because `RouteTopologyEvidenceMapper` does not exist. + +- [ ] **Step 3: 实现最小证据映射器** + +实现固定接口。`EdgeKey` 对端点排序并实现 `Comparable`,所有输出使用 `TreeMap/TreeSet` 保证确定性: + +```java +final class RouteTopologyEvidenceMapper { + List> fullMapEdges(GridMap gridMap) { + requireMap(gridMap); + Map> edges = new TreeMap>(); + for (String siteId : new TreeSet(gridMap.getSites().keySet())) { + for (String neighbor : gridMap.getNeighbors(siteId)) { + EdgeKey key = new EdgeKey(siteId, neighbor); + edges.put(key, edge(gridMap, key)); + } + } + return Collections.unmodifiableList( + new ArrayList>(edges.values())); + } + + Map compare(GridMap gridMap, List historical, + List planned) { + requireMap(gridMap); + RouteEdges historicalEdges = routeEdges(gridMap, historical, true); + RouteEdges plannedEdges = routeEdges(gridMap, planned, false); + Set shared = intersection(historicalEdges.valid, plannedEdges.valid); + Set plannedOnly = difference(plannedEdges.valid, shared); + Set historicalOnly = difference(historicalEdges.valid, shared); + Map value = new LinkedHashMap(); + value.put("sharedEdges", edgeMaps(gridMap, shared)); + value.put("plannedOnlyEdges", edgeMaps(gridMap, plannedOnly)); + value.put("historicalOnlyEdges", edgeMaps(gridMap, historicalOnly)); + value.put("invalidHistoricalSegments", historicalEdges.invalid); + value.put("sharedEdgeCount", shared.size()); + value.put("plannedOnlyEdgeCount", plannedOnly.size()); + value.put("historicalOnlyEdgeCount", historicalOnly.size()); + return value; + } +} + +private static final class RouteEdges { + private final Set valid = new TreeSet(); + private final List> invalid = + new ArrayList>(); +} + +private static final class EdgeKey implements Comparable { + private final String from; + private final String to; + + private EdgeKey(String left, String right) { + this.from = left.compareTo(right) <= 0 ? left : right; + this.to = left.compareTo(right) <= 0 ? right : left; + } + + @Override + public int compareTo(EdgeKey other) { + int fromOrder = from.compareTo(other.from); + return fromOrder != 0 ? fromOrder : to.compareTo(other.to); + } + + @Override public boolean equals(Object other) { + return other instanceof EdgeKey && compareTo((EdgeKey) other) == 0; + } + + @Override public int hashCode() { return 31 * from.hashCode() + to.hashCode(); } +} +``` + +`routeEdges` 对每个连续节点对执行两项校验:端点必须存在;右端点必须包含在左端点的 +`getNeighbors()` 中。无效历史段写入 `{fromSite,toSite,reason}`,不得写入有效边集合。其余私有 +helper 使用以下固定签名,集合操作均返回新的 `TreeSet`,不修改输入: + +```java +private RouteEdges routeEdges(GridMap map, List path, boolean reportInvalid); +private Set intersection(Set left, Set right); +private Set difference(Set source, Set removed); +private List> edgeMaps(GridMap map, Set edges); +private Map edge(GridMap map, EdgeKey key); +private Map invalid(String from, String to, String reason); +private void requireMap(GridMap map); +``` + +`edge` 固定返回 `fromSite/toSite/floor`,其中 floor 来自 `map.getSite(key.from).getFloor()`; +`routeEdges` 对未知端点写 `UNKNOWN_SITE`,对已知但不相邻端点写 `NOT_ADJACENT`。 + +- [ ] **Step 4: 运行单元测试并确认 GREEN** + +Run: + +```powershell +mvn "-Dtest=RouteTopologyEvidenceMapperTest" test +``` + +Expected: all tests in the class pass with 0 failures and 0 errors. + +- [ ] **Step 5: 提交独立证据模块** + +```powershell +git add -- javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/RouteTopologyEvidenceMapper.java javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/RouteTopologyEvidenceMapperTest.java +git commit -m "feat(javaTest): map full topology and route differences" +``` + +--- + +### Task 2: 将完整拓扑与路径差异加入可视化 API + +**Files:** +- Modify: `javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java:35-195` +- Modify: `javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java:38-102` +- Modify: `javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java:59-113` + +**Interfaces:** +- Consumes: Task 1 的 `fullMapEdges` 与 `compare`。 +- Produces: bootstrap `edges`,task `pathComparison`,现有字段保持兼容。 + +- [ ] **Step 1: 扩展 mapper 和 HTTP 失败测试** + +在 mapper 测试中从真实 `GridMap` 计算期望边数,并验证所有端点存在且无反向重复: + +```java +List> edges = listOfMaps(payload.get("edges")); +int neighborReferences = input.getGridMap().getSites().keySet().stream() + .mapToInt(id -> input.getGridMap().getNeighbors(id).size()).sum(); +assertEquals(neighborReferences / 2, edges.size()); +Set keys = new HashSet(); +for (Map edge : edges) { + String from = String.valueOf(edge.get("fromSite")); + String to = String.valueOf(edge.get("toSite")); + assertTrue(input.getGridMap().getSites().containsKey(from)); + assertTrue(input.getGridMap().getSites().containsKey(to)); + assertTrue(from.compareTo(to) < 0); + assertTrue(keys.add(from + "|" + to)); +} +``` + +在仿真结果测试中验证 `pathComparison` 存在;当前 `WRONG -> REFERENCE` 历史对照应报告无效段, +而规划路径边全部进入 `plannedOnlyEdges`。HTTP 测试增加: + +```java +assertFalse(list(map(bootstrap.body).get("edges")).isEmpty()); +Map firstTask = map(list(map(simulation.body).get("tasks")).get(0)); +assertTrue(firstTask.containsKey("pathComparison")); +``` + +- [ ] **Step 2: 运行测试并确认 RED** + +Run: + +```powershell +mvn "-Dtest=SimulationVisualizationMapperTest,HistoricalDispatchVisualizerServerTest" test +``` + +Expected: assertions fail because `edges` and `pathComparison` are absent. + +- [ ] **Step 3: 组装新证据字段** + +在 `SimulationVisualizationMapper` 中加入: + +```java +private final RouteTopologyEvidenceMapper routeTopology = + new RouteTopologyEvidenceMapper(); +``` + +`bootstrap` 在 `sites` 后写入: + +```java +payload.put("edges", routeTopology.fullMapEdges(input.getGridMap())); +``` + +把 `task(...)` 增加 `GridMap gridMap` 参数,调用处传 `input.getGridMap()`,并在路径字段后写入: + +```java +value.put("pathComparison", routeTopology.compare( + gridMap, historicalPath, task.getPlannedPath())); +``` + +不得调用 `GridPathPlanner`,不得改变 `pathChanged`、`plannedPath` 或仿真对象。 + +- [ ] **Step 4: 运行 mapper 与 HTTP 测试并确认 GREEN** + +Run: + +```powershell +mvn "-Dtest=RouteTopologyEvidenceMapperTest,SimulationVisualizationMapperTest,HistoricalDispatchVisualizerServerTest" test +``` + +Expected: all selected tests pass with 0 failures and 0 errors. + +- [ ] **Step 5: 提交 API 数据契约** + +```powershell +git add -- javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java +git commit -m "feat(javaTest): expose full-map route evidence" +``` + +--- + +### Task 3: 绘制完整楼层地图与边级路径对比 + +**Files:** +- Modify: `javaTest/src/main/resources/visualizer/index.html:109-170` +- Modify: `javaTest/src/main/resources/visualizer/assets/app.css:224-312` +- Modify: `javaTest/src/main/resources/visualizer/assets/app.js:5-430` +- Modify: `javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java:96-116` + +**Interfaces:** +- Consumes: bootstrap `sites/edges` 和 task `pathComparison`。 +- Produces: `#map-fit/#path-fit/#zoom-in/#zoom-out/#reset-view` 控件、缓存的完整底图、差异路径 SVG 类和检查器计数。 + +- [ ] **Step 1: 写静态页面契约失败测试** + +扩展静态资源测试: + +```java +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=\"route-difference\"")); +assertTrue(stylesheet.body.contains(".map-edge")); +assertTrue(stylesheet.body.contains(".planned-only")); +assertTrue(stylesheet.body.contains(".historical-only")); +assertTrue(stylesheet.body.contains(".shared-overlay")); +``` + +- [ ] **Step 2: 运行静态页面测试并确认 RED** + +Run: + +```powershell +mvn "-Dtest=HistoricalDispatchVisualizerServerTest#servesTheVisualizerShellAndAssets" test +``` + +Expected: test fails because the full-map controls and styles are absent. + +- [ ] **Step 3: 增加视口工具、图例和检查器证据** + +在楼层选择器前后加入: + +```html +
+ + +
+
+ + + +
+``` + +图例改为完整地图、接近路径、共同路段、仅新规划、仅历史、已通过、HOLD。检查器增加: + +```html +
路径边差
共同 -- / 新增 -- / 历史 --
+
历史无效段
--
+``` + +- [ ] **Step 4: 使用完整楼层坐标绘制缓存底图** + +在状态中加入: + +```javascript +routeView: { mode: "map", x: 0, y: 0, width: 1000, height: 560 }, +routeDrag: null, +projectors: {}, +renderedFloors: {} +``` + +`projectorForFloor(floor)` 必须对 `bootstrap.sites` 中该楼层全部站点计算一次稳定投影。底图只从 +`bootstrap.edges` 取当前楼层真实边: + +```javascript +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), maxX = Math.max.apply(Math, xs); + var minY = Math.min.apply(Math, ys), maxY = Math.max.apply(Math, ys); + var width = Math.max(1, maxX - minX), height = Math.max(1, maxY - minY); + var scale = Math.min(880 / width, 440 / height); + var offsetX = (1000 - width * scale) / 2; + var offsetY = (560 - height * scale) / 2; + 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, projector) { + var cache = elements["route-map"].querySelector("#map-cache-layer"); + var id = "map-floor-" + floor; + var layer = cache.querySelector("#" + id); + if (!layer) { + layer = svgElement("g", { id: id, class: "map-floor-layer" }); + state.bootstrap.edges.filter(function (edge) { return edge.floor === floor; }) + .forEach(function (edge) { + drawSegment(layer, edge.fromSite, edge.toSite, "map-edge", projector); + }); + state.bootstrap.sites.filter(function (site) { return site.floor === floor; }) + .forEach(function (site) { drawBackgroundNode(layer, site, projector); }); + cache.appendChild(layer); + } + cache.querySelectorAll(".map-floor-layer").forEach(function (item) { + item.hidden = item.id !== id; + }); +} + +function drawBackgroundNode(layer, site, projector) { + var point = projector(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); +} +``` + +SVG 内固定使用 `map-cache-layer` 和 `dynamic-route-layer`。没有任务时不再用不透明空状态盖住底图。 + +- [ ] **Step 5: 按 `pathComparison` 分层叠加差异路径** + +```javascript +function drawRouteComparison(layer, task, projector) { + var comparison = task.pathComparison; + drawEvidenceEdges(layer, comparison.sharedEdges, "shared-base planned-only", projector); + if (elements["show-historical"].checked) { + drawEvidenceEdges(layer, comparison.sharedEdges, + "shared-overlay historical-only", projector); + drawEvidenceEdges(layer, comparison.historicalOnlyEdges, + "historical-only", projector); + } + drawEvidenceEdges(layer, comparison.plannedOnlyEdges, "planned-only", projector); +} + +function comparisonNodeIds(task) { + var comparison = task.pathComparison; + var values = []; + [].concat(comparison.plannedOnlyEdges, comparison.historicalOnlyEdges) + .forEach(function (edge) { values.push(edge.fromSite, edge.toSite); }); + return new Set(values); +} + +function drawEvidenceEdges(layer, edges, className, projector) { + edges.forEach(function (edge) { + drawSegment(layer, edge.fromSite, edge.toSite, className, projector); + }); +} +``` + +接近路径继续为灰色虚线,MOVE 继续为蓝色,二者位于比较路径之上。检查器从 +`task.pathComparison` 显示三类计数及无效历史段文本。动态路径节点的 `label` 条件改为 +`start || target || hold || siteId === currentSite || comparisonNodeIds(task).has(siteId)`,不再按节点 +总数周期性显示文字。 + +`renderRoute` 的入口顺序固定为先绘制底图、再判断任务是否存在,确保页面初始化时完整地图可见: + +```javascript +function renderRoute() { + if (!state.bootstrap || state.selectedFloor === null) return; + var projector = projectorForFloor(state.selectedFloor); + renderMapBackground(state.selectedFloor, projector); + clearDynamicRouteLayer(); + var task = selectedTask(); + if (!task) { + showMapHint("等待调度结果", "完整地图已加载"); + applyRouteView(); + return; + } + hideMapHint(); + renderTaskRoute(task, projector); + applyRouteView(); +} +``` + +`loadBootstrap` 在填充站点后把 `state.selectedFloor` 设置为 `bootstrap.floors[0]`,填充楼层下拉并 +调用 `renderRoute()`;`clearDynamicRouteLayer/renderTaskRoute/showMapHint/hideMapHint` 分别只负责 +删除动态组、绘制任务叠加、显示非遮罩提示和隐藏提示,禁止清除 `map-cache-layer`。 + +- [ ] **Step 6: 添加底图与差异路径 CSS** + +```css +.map-edge { stroke: #cfd7d4; stroke-width: 1.25; opacity: .82; } +.map-site { fill: #d7dedb; stroke: #b8c3bf; stroke-width: 1; } +.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-map.is-pannable { cursor: grab; touch-action: none; } +#route-map.is-dragging { cursor: grabbing; } +``` + +- [ ] **Step 7: 运行页面与 API 测试并确认 GREEN** + +Run: + +```powershell +mvn "-Dtest=HistoricalDispatchVisualizerServerTest,SimulationVisualizationMapperTest" test +``` + +Expected: all selected tests pass, shell contains new controls, API still returns real planned path. + +- [ ] **Step 8: 提交完整地图静态视图** + +```powershell +git add -- javaTest/src/main/resources/visualizer/index.html javaTest/src/main/resources/visualizer/assets/app.css javaTest/src/main/resources/visualizer/assets/app.js javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java +git commit -m "feat(javaTest): render full-map route comparison" +``` + +--- + +### Task 4: 全图/路径适配、缩放、拖拽与楼层状态 + +**Files:** +- Modify: `javaTest/src/main/resources/visualizer/assets/app.js:48-75,240-530` +- Modify: `javaTest/src/main/resources/visualizer/assets/app.css:232-405` + +**Interfaces:** +- Consumes: Task 3 的稳定楼层 projector 和 SVG 分层。 +- Produces: 视口模式、viewBox 缩放平移、楼层路径提示和 tick 稳定视口。 + +- [ ] **Step 1: 绑定完整视口交互** + +```javascript +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); +``` + +- [ ] **Step 2: 使用 viewBox 实现适配、缩放和平移** + +```javascript +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(160, Math.min(1600, view.width * factor)); + var nextHeight = nextWidth * 0.56; + 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; + applyRouteView(); +} + +function resetRouteView() { + state.routeView = { mode: "map", x: 0, y: 0, width: 1000, height: 560 }; + updateViewModeButtons(); + applyRouteView(); +} + +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 ids; +} + +function setViewMode(mode) { + if (mode === "map") resetRouteView(); + else fitPathView(selectedRouteSiteIds(), projectorForFloor(state.selectedFloor)); + state.routeView.mode = mode; + updateViewModeButtons(); +} + +function handleRouteWheel(event) { + event.preventDefault(); + var rect = elements["route-map"].getBoundingClientRect(); + zoomRoute(event.deltaY > 0 ? 1.12 : 0.89, + (event.clientX - rect.left) / rect.width, + (event.clientY - rect.top) / rect.height); +} + +function beginRouteDrag(event) { + state.routeDrag = { x: event.clientX, y: event.clientY }; + elements["route-map"].classList.add("is-dragging"); +} + +function continueRouteDrag(event) { + if (!state.routeDrag) return; + var rect = elements["route-map"].getBoundingClientRect(); + state.routeView.x -= (event.clientX - state.routeDrag.x) * + state.routeView.width / rect.width; + state.routeView.y -= (event.clientY - state.routeDrag.y) * + state.routeView.height / rect.height; + state.routeDrag = { x: event.clientX, y: event.clientY }; + applyRouteView(); +} + +function endRouteDrag() { + state.routeDrag = null; + elements["route-map"].classList.remove("is-dragging"); +} + +function updateViewModeButtons() { + elements["map-fit"].classList.toggle("is-active", state.routeView.mode === "map"); + elements["path-fit"].classList.toggle("is-active", state.routeView.mode === "path"); +} +``` + +`fitPathView` 对当前楼层的历史、规划、接近、MOVE、A/B 和车辆节点求 SVG 坐标包围盒,并增加 +最少 48 SVG 单位边距。`setViewMode("map")` 设置 `0 0 1000 560`;推进 tick 只调用动态绘制, +不得调用 `resetRouteView`。 + +```javascript +function fitPathView(siteIds, projector) { + var points = unique(siteIds).map(siteById).filter(Boolean).map(projector).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 padding = 48; + var minX = Math.max(0, Math.min.apply(Math, xs) - padding); + var minY = Math.max(0, Math.min.apply(Math, ys) - padding); + var maxX = Math.min(1000, Math.max.apply(Math, xs) + padding); + var maxY = Math.min(560, Math.max.apply(Math, ys) + padding); + var width = Math.max(160, maxX - minX); + var height = Math.max(90, maxY - minY); + var ratio = 1000 / 560; + if (width / height > ratio) height = width / ratio; + else width = height * ratio; + state.routeView = { mode: "path", x: minX, y: minY, width: width, height: height }; + applyRouteView(); +} +``` + +- [ ] **Step 3: 让楼层选项表达路径和车辆状态** + +`populateFloors` 始终列出 bootstrap 的 1-8 楼,选项文字规则固定为: + +```javascript +var suffix = floor === vehicleFloor ? " · 车辆" : pathFloors.has(floor) ? " · 路径" : ""; +option.textContent = floor + "F" + suffix; +``` + +首次任务结果选择起点楼层;车辆跨层后只更新提示,不强制打断用户正在查看的楼层。用户切换楼层 +时按当前 `routeView.mode` 重新适配。 + +- [ ] **Step 4: 用浏览器执行交互验收** + +启动服务: + +```powershell +mvn compile exec:java "-Dexec.mainClass=com.ft546.wcs.shadow.history.visualizer.HistoricalDispatchVisualizerApplication" "-Dexec.args=18084" +``` + +Playwright 脚本必须断言: + +```javascript +const mapEdges = await page.locator( + "#map-cache-layer .map-floor-layer:not([hidden]) .map-edge").count(); +const planned = await page.locator("#dynamic-route-layer .planned-only").count(); +check(mapEdges > planned, "full map was not rendered"); +await page.locator("#path-fit").click(); +const focusedViewBox = await page.locator("#route-map").getAttribute("viewBox"); +await page.locator("#map-fit").click(); +check(focusedViewBox !== await page.locator("#route-map").getAttribute("viewBox"), + "map/path fit did not change the viewport"); +check(await page.locator("#dynamic-route-layer .historical-only").count() > 0, + "historical-only route evidence is missing"); +``` + +同时验证滚轮后 viewBox 改变、拖拽后 x/y 改变、tick 推进后 viewBox 不变、关闭历史参考后绿色 +规划仍存在、1440x900 与 390x844 无页面横向溢出、浏览器控制台无错误。保存完整地图、路径聚焦、 +安全页和移动端截图,并人工检查路径与底图非空且不遮挡。 + +- [ ] **Step 5: 提交视口交互** + +```powershell +git add -- javaTest/src/main/resources/visualizer/assets/app.js javaTest/src/main/resources/visualizer/assets/app.css +git commit -m "feat(javaTest): add route map viewport controls" +``` + +--- + +### Task 5: 文档、全量回归与最终运行 + +**Files:** +- Modify: `javaTest/README.md:99-130` + +**Interfaces:** +- Consumes: 完整地图页面最终行为。 +- Produces: 可重复启动和人工核查说明、最终绿色工作树证据。 + +- [ ] **Step 1: 更新 README 使用说明** + +在审计页面章节加入: + +```markdown +地图默认显示当前楼层完整拓扑。“全图”用于判断路径位于实际地图的通道位置,“路径”用于放大 +查看分叉细节。绿色是新规划,橙色点线是历史参考;两者重合时绿色底线上保留橙色点线。 +检查器分别统计共同边、仅新规划边、仅历史边,并报告无法映射到当前完整地图的历史段。 +``` + +- [ ] **Step 2: 运行全量 Maven 回归** + +Run: + +```powershell +mvn clean test +``` + +Expected: Surefire discovers more than 642 tests; failures 0, errors 0, skipped 0. + +- [ ] **Step 3: 检查补丁和工作树边界** + +Run: + +```powershell +git diff --check +git status --short +``` + +Expected: `git diff --check` exits 0;状态只包含本计划文件或原有未跟踪 `docs/*.html`,后者不暂存。 + +- [ ] **Step 4: 最终浏览器复验** + +重新运行 Task 4 的 Playwright 验收,并额外执行 240 条历史任务,断言完成 240、等待 0、失败 0、 +安全失败门禁 0、归档 240/240、WMS 幂等、页面控制台无错误。 + +- [ ] **Step 5: 提交文档** + +```powershell +git add -- javaTest/README.md +git commit -m "docs(javaTest): explain full-map route audit" +``` + +- [ ] **Step 6: 留下可访问的本地服务** + +启动 `HistoricalDispatchVisualizerApplication` 并验证: + +```powershell +Invoke-WebRequest -UseBasicParsing http://127.0.0.1:18084/api/health +``` + +Expected: HTTP 200,JSON 包含 `"status":"UP"`、`"mapLoaded":true` 和 +`"simulationBusy":false`。最终向使用者提供 `http://127.0.0.1:18084`。