feat(javaTest): render full-map route comparison
This commit is contained in:
@@ -58,7 +58,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
border-bottom: 1px solid var(--color-line);
|
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 {
|
.history-actions, .timeline-controls, .table-toolbar, .inline-field, .toggle-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -240,7 +240,14 @@ h3 { font-size: 14px; line-height: 1.3; }
|
|||||||
border-bottom: 1px solid var(--color-line);
|
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; }
|
.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 { gap: 7px; }
|
||||||
.inline-field select { width: auto; min-width: 74px; height: 32px; }
|
.inline-field select { width: auto; min-width: 74px; height: 32px; }
|
||||||
.toggle-field { gap: 6px; }
|
.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; }
|
.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 { 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.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.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 { width: 10px; height: 10px; border: 2px solid; border-radius: 50%; }
|
||||||
.legend-dot.hold { border-color: var(--color-amber); }
|
.legend-dot.hold { border-color: var(--color-amber); }
|
||||||
|
|
||||||
.route-canvas-wrap { position: relative; min-height: 425px; height: calc(100% - 92px); overflow: hidden; }
|
.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); }
|
.map-background { fill: url(#map-grid); }
|
||||||
.grid-line { fill: none; stroke: #e7ecea; stroke-width: 1; }
|
.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 { 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.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.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.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; }
|
.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 { 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.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; }
|
.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);
|
background: rgba(251, 252, 252, .94);
|
||||||
}
|
}
|
||||||
.empty-state strong, .loading-state strong { color: var(--color-ink); }
|
.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; }
|
.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); } }
|
@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; }
|
.button-row { flex-wrap: wrap; justify-content: flex-end; }
|
||||||
.kpi-strip { grid-template-columns: repeat(2, 1fr); }
|
.kpi-strip { grid-template-columns: repeat(2, 1fr); }
|
||||||
.section-bar { align-items: flex-start; padding: 10px 12px; }
|
.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-legend { gap: 9px; padding: 6px 12px; }
|
||||||
.route-canvas-wrap, #route-map { min-height: 340px; }
|
.route-canvas-wrap, #route-map { min-height: 340px; }
|
||||||
.evidence-list > div { grid-template-columns: 88px 1fr; padding: 9px 12px; }
|
.evidence-list > div { grid-template-columns: 88px 1fr; padding: 9px 12px; }
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
tickIndex: 0,
|
tickIndex: 0,
|
||||||
timer: null,
|
timer: null,
|
||||||
busy: false,
|
busy: false,
|
||||||
selectedFloor: null
|
selectedFloor: null,
|
||||||
|
routeView: { mode: "map", x: 0, y: 0, width: 1000, height: 560 },
|
||||||
|
routeDrag: null,
|
||||||
|
projectors: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
var elements = {};
|
var elements = {};
|
||||||
@@ -33,10 +36,12 @@
|
|||||||
"swap-sites", "run-point", "run-history", "history-run-state", "history-kpis",
|
"swap-sites", "run-point", "run-history", "history-run-state", "history-kpis",
|
||||||
"task-search", "task-status-filter", "task-filter-count", "history-task-body",
|
"task-search", "task-status-filter", "task-filter-count", "history-task-body",
|
||||||
"task-workspace", "route-map", "route-empty", "loading-state", "loading-label",
|
"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-endpoints", "evidence-vehicle", "evidence-history-vehicle",
|
||||||
"evidence-assigned-time", "evidence-waiting", "evidence-completion",
|
"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",
|
"timeline-panel", "step-back", "play-timeline", "pause-timeline", "step-forward",
|
||||||
"tick-readout", "timeline-slider", "playback-speed", "event-type-filter",
|
"tick-readout", "timeline-slider", "playback-speed", "event-type-filter",
|
||||||
"event-count", "event-table-body", "safety-view", "safety-metrics",
|
"event-count", "event-table-body", "safety-view", "safety-metrics",
|
||||||
@@ -57,8 +62,18 @@
|
|||||||
elements["floor-select"].addEventListener("change", function () {
|
elements["floor-select"].addEventListener("change", function () {
|
||||||
state.selectedFloor = Number(elements["floor-select"].value);
|
state.selectedFloor = Number(elements["floor-select"].value);
|
||||||
renderRoute();
|
renderRoute();
|
||||||
|
fitCurrentViewMode();
|
||||||
});
|
});
|
||||||
elements["show-historical"].addEventListener("change", renderRoute);
|
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 () {
|
elements["timeline-slider"].addEventListener("input", function () {
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
setTickIndex(Number(elements["timeline-slider"].value));
|
setTickIndex(Number(elements["timeline-slider"].value));
|
||||||
@@ -84,6 +99,9 @@
|
|||||||
elements["map-version"].textContent = "地图 " + shortHash(state.bootstrap.mapSha256);
|
elements["map-version"].textContent = "地图 " + shortHash(state.bootstrap.mapSha256);
|
||||||
populateSites();
|
populateSites();
|
||||||
chooseDefaultEndpoints();
|
chooseDefaultEndpoints();
|
||||||
|
populateFloors();
|
||||||
|
resetRouteView();
|
||||||
|
renderRoute();
|
||||||
setServiceStatus("本地服务正常", "pass");
|
setServiceStatus("本地服务正常", "pass");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setServiceStatus("服务不可用", "fail");
|
setServiceStatus("服务不可用", "fail");
|
||||||
@@ -116,6 +134,7 @@
|
|||||||
});
|
});
|
||||||
elements["source-site"].value = source.siteId;
|
elements["source-site"].value = source.siteId;
|
||||||
elements["target-site"].value = (sameFloor[0] || state.bootstrap.sites[1]).siteId;
|
elements["target-site"].value = (sameFloor[0] || state.bootstrap.sites[1]).siteId;
|
||||||
|
if (state.selectedFloor === null) state.selectedFloor = source.floor;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runPointSimulation(event) {
|
async function runPointSimulation(event) {
|
||||||
@@ -201,6 +220,11 @@
|
|||||||
function selectResult(result, taskId) {
|
function selectResult(result, taskId) {
|
||||||
state.selectedTaskId = taskId;
|
state.selectedTaskId = taskId;
|
||||||
state.tickIndex = 0;
|
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);
|
var taskEvents = eventsForTask(result, taskId);
|
||||||
state.eventTicks = unique(taskEvents.map(function (event) { return Number(event.tick); }))
|
state.eventTicks = unique(taskEvents.map(function (event) { return Number(event.tick); }))
|
||||||
.sort(function (left, right) { return left - right; });
|
.sort(function (left, right) { return left - right; });
|
||||||
@@ -211,6 +235,7 @@
|
|||||||
populateEventTypes(taskEvents);
|
populateEventTypes(taskEvents);
|
||||||
populateFloors();
|
populateFloors();
|
||||||
renderSelectedTask();
|
renderSelectedTask();
|
||||||
|
fitCurrentViewMode();
|
||||||
updateAcceptance(result);
|
updateAcceptance(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +256,7 @@
|
|||||||
function renderSelectedTask() {
|
function renderSelectedTask() {
|
||||||
var task = selectedTask();
|
var task = selectedTask();
|
||||||
if (!task) {
|
if (!task) {
|
||||||
showEmpty("等待调度结果", "路径证据将在本区域显示");
|
renderRoute();
|
||||||
resetInspector();
|
resetInspector();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -252,6 +277,12 @@
|
|||||||
setText("evidence-completion", formatDuration(task.simulationDurationMillis));
|
setText("evidence-completion", formatDuration(task.simulationDurationMillis));
|
||||||
setText("evidence-hold-retry", task.holdCount + " / " + task.retryCount);
|
setText("evidence-hold-retry", task.holdCount + " / " + task.retryCount);
|
||||||
setText("evidence-plc", String(task.protocolAttempts));
|
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
|
elements["planned-path-output"].textContent = task.plannedPath.length
|
||||||
? task.plannedPath.join(" → ") : "无规划路径";
|
? task.plannedPath.join(" → ") : "无规划路径";
|
||||||
elements["reason-output"].textContent = task.reason || task.status;
|
elements["reason-output"].textContent = task.reason || task.status;
|
||||||
@@ -259,20 +290,36 @@
|
|||||||
|
|
||||||
function populateFloors() {
|
function populateFloors() {
|
||||||
var task = selectedTask();
|
var task = selectedTask();
|
||||||
if (!task || !state.bootstrap) return;
|
if (!state.bootstrap) return;
|
||||||
var ids = [].concat(task.plannedPath || [], task.historicalPath || []);
|
var pathFloors = new Set();
|
||||||
eventsForTask(currentResult(), task.simulationTaskId).forEach(function (event) {
|
var vehicleFloor = null;
|
||||||
if (event.fromSite) ids.push(event.fromSite);
|
if (task) {
|
||||||
if (event.toSite) ids.push(event.toSite);
|
var ids = [].concat(task.plannedPath || [], task.historicalPath || []);
|
||||||
});
|
var events = eventsForTask(currentResult(), task.simulationTaskId);
|
||||||
var floors = unique(ids.map(siteById).filter(Boolean).map(function (site) { return site.floor; }));
|
events.forEach(function (event) {
|
||||||
if (!floors.length) floors = state.bootstrap.floors.slice();
|
if (event.fromSite) ids.push(event.fromSite);
|
||||||
state.selectedFloor = floors.indexOf(state.selectedFloor) >= 0 ? state.selectedFloor : floors[0];
|
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();
|
var fragment = document.createDocumentFragment();
|
||||||
floors.forEach(function (floor) {
|
floors.forEach(function (floor) {
|
||||||
var option = document.createElement("option");
|
var option = document.createElement("option");
|
||||||
option.value = String(floor);
|
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;
|
option.selected = floor === state.selectedFloor;
|
||||||
fragment.appendChild(option);
|
fragment.appendChild(option);
|
||||||
});
|
});
|
||||||
@@ -280,13 +327,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderRoute() {
|
function renderRoute() {
|
||||||
var task = selectedTask();
|
if (!state.bootstrap || state.selectedFloor === null) return;
|
||||||
if (!task || !state.bootstrap) return;
|
|
||||||
var svg = elements["route-map"];
|
var svg = elements["route-map"];
|
||||||
var old = svg.querySelector("#dynamic-route-layer");
|
var project = projectorForFloor(state.selectedFloor);
|
||||||
if (old) old.remove();
|
renderMapBackground(state.selectedFloor, project);
|
||||||
var layer = svgElement("g", { id: "dynamic-route-layer" });
|
var layer = svg.querySelector("#dynamic-route-layer");
|
||||||
svg.appendChild(layer);
|
layer.replaceChildren();
|
||||||
|
|
||||||
|
var task = selectedTask();
|
||||||
|
if (!task) {
|
||||||
|
showMapHint("等待调度结果", "当前楼层完整地图已加载");
|
||||||
|
applyRouteView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var events = eventsForTask(currentResult(), task.simulationTaskId);
|
var events = eventsForTask(currentResult(), task.simulationTaskId);
|
||||||
var moves = events.filter(function (event) { return event.eventType === "MOVE"; });
|
var moves = events.filter(function (event) { return event.eventType === "MOVE"; });
|
||||||
@@ -305,15 +358,14 @@
|
|||||||
return site && site.floor === state.selectedFloor;
|
return site && site.floor === state.selectedFloor;
|
||||||
});
|
});
|
||||||
if (!sites.length) {
|
if (!sites.length) {
|
||||||
showEmpty("当前楼层没有路径节点", "请选择包含任务路径的楼层");
|
showMapHint("当前楼层无任务路径", "完整地图仍可检查,可切换带“路径”的楼层");
|
||||||
|
applyRouteView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
elements["route-empty"].hidden = true;
|
hideMapHint();
|
||||||
var project = projector(sites);
|
|
||||||
|
|
||||||
drawPath(layer, historical, "historical", project);
|
|
||||||
drawPath(layer, approach, "approach", project);
|
drawPath(layer, approach, "approach", project);
|
||||||
drawPath(layer, task.plannedPath, "service", project);
|
drawRouteComparison(layer, task, project);
|
||||||
drawMoveSegments(layer, traversedMoves, "traversed", project);
|
drawMoveSegments(layer, traversedMoves, "traversed", project);
|
||||||
|
|
||||||
var passedIds = new Set();
|
var passedIds = new Set();
|
||||||
@@ -324,14 +376,15 @@
|
|||||||
var lastEvent = lastEventAt(events, currentTick);
|
var lastEvent = lastEventAt(events, currentTick);
|
||||||
var holdSite = lastEvent && isHold(lastEvent.eventType)
|
var holdSite = lastEvent && isHold(lastEvent.eventType)
|
||||||
? (lastEvent.toSite || lastEvent.fromSite) : null;
|
? (lastEvent.toSite || lastEvent.fromSite) : null;
|
||||||
var labelEvery = sites.length <= 12 ? 1 : sites.length <= 28 ? 3 : 5;
|
var comparisonNodes = comparisonNodeIds(task,
|
||||||
sites.forEach(function (site, index) {
|
elements["show-historical"].checked);
|
||||||
|
sites.forEach(function (site) {
|
||||||
drawNode(layer, site, project, {
|
drawNode(layer, site, project, {
|
||||||
passed: passedIds.has(site.siteId),
|
passed: passedIds.has(site.siteId),
|
||||||
start: site.siteId === task.physicalStartSite,
|
start: site.siteId === task.physicalStartSite,
|
||||||
target: site.siteId === task.physicalEndSite,
|
target: site.siteId === task.physicalEndSite,
|
||||||
hold: site.siteId === holdSite,
|
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
|
|| site.siteId === task.physicalStartSite || site.siteId === task.physicalEndSite
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -339,9 +392,15 @@
|
|||||||
if (vehicleSite && vehicleSite.floor === state.selectedFloor) {
|
if (vehicleSite && vehicleSite.floor === state.selectedFloor) {
|
||||||
drawVehicle(layer, vehicleSite, task.simulatedVehicleId, project);
|
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 xs = sites.map(function (site) { return Number(site.x); });
|
||||||
var ys = sites.map(function (site) { return Number(site.y); });
|
var ys = sites.map(function (site) { return Number(site.y); });
|
||||||
var minX = Math.min.apply(Math, xs);
|
var minX = Math.min.apply(Math, xs);
|
||||||
@@ -350,18 +409,126 @@
|
|||||||
var maxY = Math.max.apply(Math, ys);
|
var maxY = Math.max.apply(Math, ys);
|
||||||
var width = Math.max(1, maxX - minX);
|
var width = Math.max(1, maxX - minX);
|
||||||
var height = Math.max(1, maxY - minY);
|
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 drawWidth = width * scale;
|
||||||
var drawHeight = height * scale;
|
var drawHeight = height * scale;
|
||||||
var offsetX = (1000 - drawWidth) / 2;
|
var offsetX = (1000 - drawWidth) / 2;
|
||||||
var offsetY = (560 - drawHeight) / 2;
|
var offsetY = (560 - drawHeight) / 2;
|
||||||
return function (site) {
|
state.projectors[floor] = function (site) {
|
||||||
if (!site || site.floor !== state.selectedFloor) return null;
|
if (!site || site.floor !== floor) return null;
|
||||||
return {
|
return {
|
||||||
x: offsetX + (Number(site.x) - minX) * scale,
|
x: offsetX + (Number(site.x) - minX) * scale,
|
||||||
y: 560 - offsetY - (Number(site.y) - minY) * 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) {
|
function drawPath(layer, ids, className, project) {
|
||||||
@@ -406,11 +573,19 @@
|
|||||||
circle.appendChild(title);
|
circle.appendChild(title);
|
||||||
layer.appendChild(circle);
|
layer.appendChild(circle);
|
||||||
if (flags.label) {
|
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;
|
label.textContent = site.siteId;
|
||||||
layer.appendChild(label);
|
layer.appendChild(label);
|
||||||
if (flags.start || flags.target) {
|
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 + ")";
|
coordinate.textContent = "(" + site.x + ", " + site.y + ")";
|
||||||
layer.appendChild(coordinate);
|
layer.appendChild(coordinate);
|
||||||
}
|
}
|
||||||
@@ -432,10 +607,158 @@
|
|||||||
layer.appendChild(marker);
|
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() {
|
function renderTimeline() {
|
||||||
elements["timeline-slider"].value = String(state.tickIndex);
|
elements["timeline-slider"].value = String(state.tickIndex);
|
||||||
elements["tick-readout"].textContent = "tick " + tickValue();
|
elements["tick-readout"].textContent = "tick " + tickValue();
|
||||||
renderRoute();
|
renderRoute();
|
||||||
|
populateFloors();
|
||||||
renderEventTable();
|
renderEventTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +1069,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showEmpty(title, detail) {
|
function showEmpty(title, detail) {
|
||||||
|
elements["route-empty"].classList.remove("is-map-hint");
|
||||||
elements["route-empty"].hidden = false;
|
elements["route-empty"].hidden = false;
|
||||||
var strong = elements["route-empty"].querySelector("strong");
|
var strong = elements["route-empty"].querySelector("strong");
|
||||||
var span = elements["route-empty"].querySelector("span");
|
var span = elements["route-empty"].querySelector("span");
|
||||||
@@ -756,7 +1080,8 @@
|
|||||||
function resetInspector() {
|
function resetInspector() {
|
||||||
["inspector-task-id", "evidence-endpoints", "evidence-vehicle", "evidence-history-vehicle",
|
["inspector-task-id", "evidence-endpoints", "evidence-vehicle", "evidence-history-vehicle",
|
||||||
"evidence-assigned-time", "evidence-waiting", "evidence-completion",
|
"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, "--"); });
|
.forEach(function (id) { setText(id, "--"); });
|
||||||
setTaskStatus("--");
|
setTaskStatus("--");
|
||||||
}
|
}
|
||||||
@@ -835,6 +1160,22 @@
|
|||||||
return value === null || value === undefined ? "--" : "T+" + formatDuration(value);
|
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 setText(id, value) { elements[id].textContent = value; }
|
||||||
function shortHash(value) { return value ? value.slice(0, 10) : "--"; }
|
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); }
|
function manhattan(left, right) { return Math.abs(left.x - right.x) + Math.abs(left.y - right.y); }
|
||||||
|
|||||||
@@ -114,15 +114,26 @@
|
|||||||
<h2 id="route-title">算法规划路径</h2>
|
<h2 id="route-title">算法规划路径</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="route-tools">
|
<div class="route-tools">
|
||||||
|
<div class="view-mode" role="group" aria-label="地图视口">
|
||||||
|
<button id="map-fit" type="button" class="view-mode-button is-active">全图</button>
|
||||||
|
<button id="path-fit" type="button" class="view-mode-button">路径</button>
|
||||||
|
</div>
|
||||||
|
<div class="map-actions" role="group" aria-label="地图缩放">
|
||||||
|
<button id="zoom-out" type="button" class="icon-button compact" title="缩小地图" aria-label="缩小地图">−</button>
|
||||||
|
<button id="zoom-in" type="button" class="icon-button compact" title="放大地图" aria-label="放大地图">+</button>
|
||||||
|
<button id="reset-view" type="button" class="icon-button compact" title="复位为完整地图" aria-label="复位为完整地图">⌂</button>
|
||||||
|
</div>
|
||||||
<label class="inline-field"><span>楼层</span><select id="floor-select"></select></label>
|
<label class="inline-field"><span>楼层</span><select id="floor-select"></select></label>
|
||||||
<label class="toggle-field"><input id="show-historical" type="checkbox" checked><span>历史参考</span></label>
|
<label class="toggle-field"><input id="show-historical" type="checkbox" checked><span>历史参考</span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="route-legend" aria-label="路径图例">
|
<div class="route-legend" aria-label="路径图例">
|
||||||
|
<span><i class="legend-line map-base"></i>完整地图</span>
|
||||||
<span><i class="legend-line approach"></i>接近路径</span>
|
<span><i class="legend-line approach"></i>接近路径</span>
|
||||||
<span><i class="legend-line service"></i>A → B 规划</span>
|
<span><i class="legend-line shared"></i>共同路段</span>
|
||||||
|
<span><i class="legend-line planned-only"></i>仅新规划</span>
|
||||||
|
<span><i class="legend-line historical-only"></i>仅历史</span>
|
||||||
<span><i class="legend-line traversed"></i>已通过</span>
|
<span><i class="legend-line traversed"></i>已通过</span>
|
||||||
<span><i class="legend-line historical"></i>历史参考</span>
|
|
||||||
<span><i class="legend-dot hold"></i>HOLD</span>
|
<span><i class="legend-dot hold"></i>HOLD</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="route-canvas-wrap">
|
<div class="route-canvas-wrap">
|
||||||
@@ -133,6 +144,8 @@
|
|||||||
</pattern>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1000" height="560" class="map-background"></rect>
|
<rect width="1000" height="560" class="map-background"></rect>
|
||||||
|
<g id="map-cache-layer"></g>
|
||||||
|
<g id="dynamic-route-layer"></g>
|
||||||
</svg>
|
</svg>
|
||||||
<div id="route-empty" class="empty-state">
|
<div id="route-empty" class="empty-state">
|
||||||
<strong>等待调度结果</strong>
|
<strong>等待调度结果</strong>
|
||||||
@@ -159,6 +172,8 @@
|
|||||||
<div><dt>完成时间</dt><dd id="evidence-completion">--</dd></div>
|
<div><dt>完成时间</dt><dd id="evidence-completion">--</dd></div>
|
||||||
<div><dt>HOLD / 重试</dt><dd id="evidence-hold-retry">--</dd></div>
|
<div><dt>HOLD / 重试</dt><dd id="evidence-hold-retry">--</dd></div>
|
||||||
<div><dt>PLC 尝试</dt><dd id="evidence-plc">--</dd></div>
|
<div><dt>PLC 尝试</dt><dd id="evidence-plc">--</dd></div>
|
||||||
|
<div><dt>路径边差</dt><dd id="route-difference">共同 -- / 新增 -- / 历史 --</dd></div>
|
||||||
|
<div><dt>历史无效段</dt><dd id="invalid-history-segments">--</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="path-proof">
|
<div class="path-proof">
|
||||||
<span>调度中心 plannedPath</span>
|
<span>调度中心 plannedPath</span>
|
||||||
|
|||||||
@@ -109,9 +109,20 @@ class HistoricalDispatchVisualizerServerTest {
|
|||||||
assertTrue(page.body.contains("调度路径审计台"));
|
assertTrue(page.body.contains("调度路径审计台"));
|
||||||
assertTrue(page.body.contains("id=\"route-map\""));
|
assertTrue(page.body.contains("id=\"route-map\""));
|
||||||
assertTrue(page.body.contains("id=\"timeline-slider\""));
|
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(200, stylesheet.status);
|
||||||
assertEquals("text/css; charset=utf-8", stylesheet.contentType);
|
assertEquals("text/css; charset=utf-8", stylesheet.contentType);
|
||||||
assertTrue(stylesheet.body.contains("--color-service-path"));
|
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(200, script.status);
|
||||||
assertEquals("application/javascript; charset=utf-8", script.contentType);
|
assertEquals("application/javascript; charset=utf-8", script.contentType);
|
||||||
assertTrue(script.body.contains("/api/simulations/point-to-point"));
|
assertTrue(script.body.contains("/api/simulations/point-to-point"));
|
||||||
|
|||||||
Reference in New Issue
Block a user