Files
FT546-WCS/docs/superpowers/plans/2026-08-05-javaTest-full-map-route-comparison.md

778 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<Map<String,Object>> fullMapEdges(GridMap)` 和 `Map<String,Object> compare(GridMap,List<String>,List<String>)`。
- [ ] **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<Map<String, Object>> edges = mapper.fullMapEdges(map);
Map<String, Object> 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<String, Object> comparison = new RouteTopologyEvidenceMapper().compare(map,
Arrays.asList("A", "UNKNOWN", "C"), Arrays.asList("A", "B", "C"));
List<Map<String, Object>> 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<Map<String, Object>> listOfMaps(Object value) {
return (List<Map<String, Object>>) 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<Map<String, Object>> fullMapEdges(GridMap gridMap) {
requireMap(gridMap);
Map<EdgeKey, Map<String, Object>> edges = new TreeMap<EdgeKey, Map<String, Object>>();
for (String siteId : new TreeSet<String>(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<Map<String, Object>>(edges.values()));
}
Map<String, Object> compare(GridMap gridMap, List<String> historical,
List<String> planned) {
requireMap(gridMap);
RouteEdges historicalEdges = routeEdges(gridMap, historical, true);
RouteEdges plannedEdges = routeEdges(gridMap, planned, false);
Set<EdgeKey> shared = intersection(historicalEdges.valid, plannedEdges.valid);
Set<EdgeKey> plannedOnly = difference(plannedEdges.valid, shared);
Set<EdgeKey> historicalOnly = difference(historicalEdges.valid, shared);
Map<String, Object> value = new LinkedHashMap<String, Object>();
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<EdgeKey> valid = new TreeSet<EdgeKey>();
private final List<Map<String, Object>> invalid =
new ArrayList<Map<String, Object>>();
}
private static final class EdgeKey implements Comparable<EdgeKey> {
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<EdgeKey>`,不修改输入:
```java
private RouteEdges routeEdges(GridMap map, List<String> path, boolean reportInvalid);
private Set<EdgeKey> intersection(Set<EdgeKey> left, Set<EdgeKey> right);
private Set<EdgeKey> difference(Set<EdgeKey> source, Set<EdgeKey> removed);
private List<Map<String, Object>> edgeMaps(GridMap map, Set<EdgeKey> edges);
private Map<String, Object> edge(GridMap map, EdgeKey key);
private Map<String, Object> 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<Map<String, Object>> 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<String> keys = new HashSet<String>();
for (Map<String, Object> 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<String, Object> 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
<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" title="缩小">−</button>
<button id="zoom-in" type="button" class="icon-button" title="放大">+</button>
<button id="reset-view" type="button" class="icon-button" title="复位视口">⌂</button>
</div>
```
图例改为完整地图、接近路径、共同路段、仅新规划、仅历史、已通过、HOLD。检查器增加:
```html
<div><dt>路径边差</dt><dd id="route-difference">共同 -- / 新增 -- / 历史 --</dd></div>
<div><dt>历史无效段</dt><dd id="invalid-history-segments">--</dd></div>
```
- [ ] **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`。