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

31 KiB
Raw Blame History

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:

@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:

mvn "-Dtest=RouteTopologyEvidenceMapperTest" test

Expected: test compilation fails because RouteTopologyEvidenceMapper does not exist.

  • Step 3: 实现最小证据映射器

实现固定接口。EdgeKey 对端点排序并实现 Comparable,所有输出使用 TreeMap/TreeSet 保证确定性:

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>,不修改输入:

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:

mvn "-Dtest=RouteTopologyEvidenceMapperTest" test

Expected: all tests in the class pass with 0 failures and 0 errors.

  • Step 5: 提交独立证据模块
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 计算期望边数,并验证所有端点存在且无反向重复:

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 测试增加:

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:

mvn "-Dtest=SimulationVisualizationMapperTest,HistoricalDispatchVisualizerServerTest" test

Expected: assertions fail because edges and pathComparison are absent.

  • Step 3: 组装新证据字段

在 SimulationVisualizationMapper 中加入:

private final RouteTopologyEvidenceMapper routeTopology =
        new RouteTopologyEvidenceMapper();

bootstrap 在 sites 后写入:

payload.put("edges", routeTopology.fullMapEdges(input.getGridMap()));

把 task(...) 增加 GridMap gridMap 参数,调用处传 input.getGridMap(),并在路径字段后写入:

value.put("pathComparison", routeTopology.compare(
        gridMap, historicalPath, task.getPlannedPath()));

不得调用 GridPathPlanner,不得改变 pathChanged、plannedPath 或仿真对象。

  • Step 4: 运行 mapper 与 HTTP 测试并确认 GREEN

Run:

mvn "-Dtest=RouteTopologyEvidenceMapperTest,SimulationVisualizationMapperTest,HistoricalDispatchVisualizerServerTest" test

Expected: all selected tests pass with 0 failures and 0 errors.

  • Step 5: 提交 API 数据契约
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: 写静态页面契约失败测试

扩展静态资源测试:

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:

mvn "-Dtest=HistoricalDispatchVisualizerServerTest#servesTheVisualizerShellAndAssets" test

Expected: test fails because the full-map controls and styles are absent.

  • Step 3: 增加视口工具、图例和检查器证据

在楼层选择器前后加入:

<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。检查器增加:

<div><dt>路径边差</dt><dd id="route-difference">共同 -- / 新增 -- / 历史 --</dd></div>
<div><dt>历史无效段</dt><dd id="invalid-history-segments">--</dd></div>
  • Step 4: 使用完整楼层坐标绘制缓存底图

在状态中加入:

routeView: { mode: "map", x: 0, y: 0, width: 1000, height: 560 },
routeDrag: null,
projectors: {},
renderedFloors: {}

projectorForFloor(floor) 必须对 bootstrap.sites 中该楼层全部站点计算一次稳定投影。底图只从 bootstrap.edges 取当前楼层真实边:

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 分层叠加差异路径
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 的入口顺序固定为先绘制底图、再判断任务是否存在,确保页面初始化时完整地图可见:

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
.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:

mvn "-Dtest=HistoricalDispatchVisualizerServerTest,SimulationVisualizationMapperTest" test

Expected: all selected tests pass, shell contains new controls, API still returns real planned path.

  • Step 8: 提交完整地图静态视图
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: 绑定完整视口交互

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 实现适配、缩放和平移
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。

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 楼,选项文字规则固定为:

var suffix = floor === vehicleFloor ? " · 车辆" : pathFloors.has(floor) ? " · 路径" : "";
option.textContent = floor + "F" + suffix;

首次任务结果选择起点楼层;车辆跨层后只更新提示,不强制打断用户正在查看的楼层。用户切换楼层 时按当前 routeView.mode 重新适配。

  • Step 4: 用浏览器执行交互验收

启动服务:

mvn compile exec:java "-Dexec.mainClass=com.ft546.wcs.shadow.history.visualizer.HistoricalDispatchVisualizerApplication" "-Dexec.args=18084"

Playwright 脚本必须断言:

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: 提交视口交互
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 使用说明

在审计页面章节加入:

地图默认显示当前楼层完整拓扑。“全图”用于判断路径位于实际地图的通道位置,“路径”用于放大
查看分叉细节。绿色是新规划,橙色点线是历史参考;两者重合时绿色底线上保留橙色点线。
检查器分别统计共同边、仅新规划边、仅历史边,并报告无法映射到当前完整地图的历史段。
  • Step 2: 运行全量 Maven 回归

Run:

mvn clean test

Expected: Surefire discovers more than 642 tests; failures 0, errors 0, skipped 0.

  • Step 3: 检查补丁和工作树边界

Run:

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: 提交文档
git add -- javaTest/README.md
git commit -m "docs(javaTest): explain full-map route audit"
  • Step 6: 留下可访问的本地服务

启动 HistoricalDispatchVisualizerApplication 并验证:

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。