@@ -12,7 +12,10 @@
tickIndex : 0 ,
timer : null ,
busy : false ,
selectedFloor : null
selectedFloor : null ,
routeView : { mode : "map" , x : 0 , y : 0 , width : 1000 , height : 560 } ,
routeDrag : null ,
projectors : { }
} ;
var elements = { } ;
@@ -33,10 +36,12 @@
"swap-sites" , "run-point" , "run-history" , "history-run-state" , "history-kpis" ,
"task-search" , "task-status-filter" , "task-filter-count" , "history-task-body" ,
"task-workspace" , "route-map" , "route-empty" , "loading-state" , "loading-label" ,
"floor-select" , "show-historical" , "inspector-task -id " , "task-status " ,
"floor-select" , "show-historical" , "map-fit" , "path-fit" , "zoom -in " , "zoom-out " ,
"reset-view" , "inspector-task-id" , "task-status" ,
"evidence-endpoints" , "evidence-vehicle" , "evidence-history-vehicle" ,
"evidence-assigned-time" , "evidence-waiting" , "evidence-completion" ,
"evidence-hold-retry" , "evidence-plc" , "planned-path-output" , "reason-output " ,
"evidence-hold-retry" , "evidence-plc" , "route-difference " ,
"invalid-history-segments" , "planned-path-output" , "reason-output" ,
"timeline-panel" , "step-back" , "play-timeline" , "pause-timeline" , "step-forward" ,
"tick-readout" , "timeline-slider" , "playback-speed" , "event-type-filter" ,
"event-count" , "event-table-body" , "safety-view" , "safety-metrics" ,
@@ -57,8 +62,18 @@
elements [ "floor-select" ] . addEventListener ( "change" , function ( ) {
state . selectedFloor = Number ( elements [ "floor-select" ] . value ) ;
renderRoute ( ) ;
fitCurrentViewMode ( ) ;
} ) ;
elements [ "show-historical" ] . addEventListener ( "change" , renderRoute ) ;
elements [ "map-fit" ] . addEventListener ( "click" , function ( ) { setViewMode ( "map" ) ; } ) ;
elements [ "path-fit" ] . addEventListener ( "click" , function ( ) { setViewMode ( "path" ) ; } ) ;
elements [ "zoom-in" ] . addEventListener ( "click" , function ( ) { zoomRoute ( 0.8 ) ; } ) ;
elements [ "zoom-out" ] . addEventListener ( "click" , function ( ) { zoomRoute ( 1.25 ) ; } ) ;
elements [ "reset-view" ] . addEventListener ( "click" , resetRouteView ) ;
elements [ "route-map" ] . addEventListener ( "wheel" , handleRouteWheel , { passive : false } ) ;
elements [ "route-map" ] . addEventListener ( "pointerdown" , beginRouteDrag ) ;
window . addEventListener ( "pointermove" , continueRouteDrag ) ;
window . addEventListener ( "pointerup" , endRouteDrag ) ;
elements [ "timeline-slider" ] . addEventListener ( "input" , function ( ) {
stopPlayback ( ) ;
setTickIndex ( Number ( elements [ "timeline-slider" ] . value ) ) ;
@@ -84,6 +99,9 @@
elements [ "map-version" ] . textContent = "地图 " + shortHash ( state . bootstrap . mapSha256 ) ;
populateSites ( ) ;
chooseDefaultEndpoints ( ) ;
populateFloors ( ) ;
resetRouteView ( ) ;
renderRoute ( ) ;
setServiceStatus ( "本地服务正常" , "pass" ) ;
} catch ( error ) {
setServiceStatus ( "服务不可用" , "fail" ) ;
@@ -116,6 +134,7 @@
} ) ;
elements [ "source-site" ] . value = source . siteId ;
elements [ "target-site" ] . value = ( sameFloor [ 0 ] || state . bootstrap . sites [ 1 ] ) . siteId ;
if ( state . selectedFloor === null ) state . selectedFloor = source . floor ;
}
async function runPointSimulation ( event ) {
@@ -201,6 +220,11 @@
function selectResult ( result , taskId ) {
state . selectedTaskId = taskId ;
state . tickIndex = 0 ;
var selected = result . tasks . find ( function ( task ) {
return task . simulationTaskId === taskId ;
} ) ;
var startSite = selected ? siteById ( selected . physicalStartSite ) : null ;
if ( startSite ) state . selectedFloor = startSite . floor ;
var taskEvents = eventsForTask ( result , taskId ) ;
state . eventTicks = unique ( taskEvents . map ( function ( event ) { return Number ( event . tick ) ; } ) )
. sort ( function ( left , right ) { return left - right ; } ) ;
@@ -211,6 +235,7 @@
populateEventTypes ( taskEvents ) ;
populateFloors ( ) ;
renderSelectedTask ( ) ;
fitCurrentViewMode ( ) ;
updateAcceptance ( result ) ;
}
@@ -231,7 +256,7 @@
function renderSelectedTask ( ) {
var task = selectedTask ( ) ;
if ( ! task ) {
showEmpty ( "等待调度结果" , "路径证据将在本区域显示" ) ;
renderRoute ( ) ;
resetInspector ( ) ;
return ;
}
@@ -252,6 +277,12 @@
setText ( "evidence-completion" , formatDuration ( task . simulationDurationMillis ) ) ;
setText ( "evidence-hold-retry" , task . holdCount + " / " + task . retryCount ) ;
setText ( "evidence-plc" , String ( task . protocolAttempts ) ) ;
var comparison = task . pathComparison || emptyPathComparison ( ) ;
setText ( "route-difference" , "共同 " + comparison . sharedEdgeCount
+ " / 新增 " + comparison . plannedOnlyEdgeCount
+ " / 历史 " + comparison . historicalOnlyEdgeCount ) ;
setText ( "invalid-history-segments" , formatInvalidSegments (
comparison . invalidHistoricalSegments ) ) ;
elements [ "planned-path-output" ] . textContent = task . plannedPath . length
? task . plannedPath . join ( " → " ) : "无规划路径" ;
elements [ "reason-output" ] . textContent = task . reason || task . status ;
@@ -259,20 +290,36 @@
function populateFloors ( ) {
var task = selectedTask ( ) ;
if ( ! task || ! state. bootstrap ) return ;
var ids = [ ] . concat ( task . plannedPath || [ ] , task . historicalPath || [ ] ) ;
eventsForTask ( currentResult ( ) , task . simulationTaskId ) . forEach ( function ( event ) {
if ( event . fromSite ) ids . push ( event . fromSite ) ;
if ( event . toSite ) ids . push ( event . toSite ) ;
} ) ;
var floors = unique ( ids . map ( siteById ) . filter ( Boolean ) . map ( function ( site ) { return site . floor ; } ) ) ;
if ( ! floors . length ) floors = state . bootstrap . floors . slice ( ) ;
state . selectedFloor = floors . indexO f( state . selectedFloor ) >= 0 ? state . selectedFloor : floors [ 0 ] ;
if ( ! state . bootstrap ) return ;
var pathFloors = new Set ( ) ;
var vehicleFloor = null ;
if ( task ) {
var ids = [ ] . concat ( task . plannedPath || [ ] , task . historicalPath || [ ] ) ;
var events = eventsForTask ( currentResult ( ) , task . simulationTaskId ) ;
events . forEach ( function ( event ) {
if ( event . fromSite ) ids . push ( event . fromSite ) ;
i f ( event . toSite ) ids . push ( event . toSite ) ;
} ) ;
ids . map ( siteById ) . filter ( Boolean ) . forEach ( function ( site ) {
pathFloors . add ( site . floor ) ;
} ) ;
var moves = events . filter ( function ( event ) {
return event . eventType === "MOVE" && Number ( event . tick ) <= tickValue ( ) ;
} ) ;
var vehicleSite = siteById ( currentVehicleSite ( task , moves ) ) ;
if ( vehicleSite ) vehicleFloor = vehicleSite . floor ;
}
var floors = state . bootstrap . floors . slice ( ) ;
state . selectedFloor = floors . indexOf ( state . selectedFloor ) >= 0
? state . selectedFloor : floors [ 0 ] ;
var fragment = document . createDocumentFragment ( ) ;
floors . forEach ( function ( floor ) {
var option = document . createElement ( "option" ) ;
option . value = String ( floor ) ;
option . textContent = floor + "F" ;
var flags = [ ] ;
if ( pathFloors . has ( floor ) ) flags . push ( "路径" ) ;
if ( floor === vehicleFloor ) flags . push ( "车辆" ) ;
option . textContent = floor + "F" + ( flags . length ? " · " + flags . join ( " · " ) : "" ) ;
option . selected = floor === state . selectedFloor ;
fragment . appendChild ( option ) ;
} ) ;
@@ -280,13 +327,19 @@
}
function renderRoute ( ) {
var task = selectedTask ( ) ;
if ( ! task || ! state . bootstrap ) return ;
if ( ! state . bootstrap || state . selectedFloor === null ) return ;
var svg = elements [ "route-map" ] ;
var old = svg . querySelector ( "#dynamic-route-layer" ) ;
if ( old ) old . remove ( ) ;
var layer = svgElement ( "g" , { id : " dynamic-route-layer" } ) ;
svg . appendChild ( layer ) ;
var project = projectorForFloor ( state . selectedFloor ) ;
renderMapBackground ( state . selectedFloor , project ) ;
var layer = svg. querySelector ( "# dynamic-route-layer" ) ;
layer . replaceChildren ( ) ;
var task = selectedTask ( ) ;
if ( ! task ) {
showMapHint ( "等待调度结果" , "当前楼层完整地图已加载" ) ;
applyRouteView ( ) ;
return ;
}
var events = eventsForTask ( currentResult ( ) , task . simulationTaskId ) ;
var moves = events . filter ( function ( event ) { return event . eventType === "MOVE" ; } ) ;
@@ -305,15 +358,14 @@
return site && site . floor === state . selectedFloor ;
} ) ;
if ( ! sites . length ) {
showEmpty ( "当前楼层没有路径节点 " , "请选择包含任务 路径的楼层" ) ;
showMapHint ( "当前楼层无任务路径 " , "完整地图仍可检查,可切换带“ 路径” 的楼层" ) ;
applyRouteView ( ) ;
return ;
}
elements [ "route-empty" ] . hidden = true ;
var project = projector ( sites ) ;
hideMapHint ( ) ;
drawPath ( layer , historical , "historical" , project ) ;
drawPath ( layer , approach , "approach" , project ) ;
drawPath ( layer , task . plannedPath , "service" , project ) ;
drawRouteComparison ( layer , task , project ) ;
drawMoveSegments ( layer , traversedMoves , "traversed" , project ) ;
var passedIds = new Set ( ) ;
@@ -324,14 +376,15 @@
var lastEvent = lastEventAt ( events , currentTick ) ;
var holdSite = lastEvent && isHold ( lastEvent . eventType )
? ( lastEvent . toSite || lastEvent . fromSite ) : null ;
var labelEvery = sites . length < = 12 ? 1 : site s. length <= 28 ? 3 : 5 ;
sites . forEach ( function ( site , index ) {
var comparisonNodes = comparisonNodeId s( task ,
elements [ "show-historical" ] . checked ) ;
sites . forEach ( function ( site ) {
drawNode ( layer , site , project , {
passed : passedIds . has ( site . siteId ) ,
start : site . siteId === task . physicalStartSite ,
target : site . siteId === task . physicalEndSite ,
hold : site . siteId === holdSite ,
label : index % labelEvery === 0 || site . siteId === currentSite
label : comparisonNodes . has ( site . siteId ) || site . siteId === currentSite
|| site . siteId === task . physicalStartSite || site . siteId === task . physicalEndSite
} ) ;
} ) ;
@@ -339,9 +392,15 @@
if ( vehicleSite && vehicleSite . floor === state . selectedFloor ) {
drawVehicle ( layer , vehicleSite , task . simulatedVehicleId , project ) ;
}
applyRouteView ( ) ;
}
function projector ( sites ) {
/** 使用当前楼层全部站点建立稳定投影,切换任务和 tick 时地图位置不会跳动。 */
function projectorForFloor ( floor ) {
if ( state . projectors [ floor ] ) return state . projectors [ floor ] ;
var sites = state . bootstrap . sites . filter ( function ( site ) {
return site . floor === floor ;
} ) ;
var xs = sites . map ( function ( site ) { return Number ( site . x ) ; } ) ;
var ys = sites . map ( function ( site ) { return Number ( site . y ) ; } ) ;
var minX = Math . min . apply ( Math , xs ) ;
@@ -350,18 +409,126 @@
var maxY = Math . max . apply ( Math , ys ) ;
var width = Math . max ( 1 , maxX - minX ) ;
var height = Math . max ( 1 , maxY - minY ) ;
var scale = Math . min ( 86 0 / width , 43 0 / height ) ;
var scale = Math . min ( 88 0 / width , 44 0 / height ) ;
var drawWidth = width * scale ;
var drawHeight = height * scale ;
var offsetX = ( 1000 - drawWidth ) / 2 ;
var offsetY = ( 560 - drawHeight ) / 2 ;
return function ( site ) {
if ( ! site || site . floor !== state . selectedF loor) return null ;
state . projectors [ floor ] = function ( site ) {
if ( ! site || site . floor !== f loor) return null ;
return {
x : offsetX + ( Number ( site . x ) - minX ) * scale ,
y : 560 - offsetY - ( Number ( site . y ) - minY ) * scale
} ;
} ;
return state . projectors [ floor ] ;
}
/** 首次查看楼层时绘制并缓存权威道路与站点;时间线推进只更新动态层。 */
function renderMapBackground ( floor , project ) {
var cache = elements [ "route-map" ] . querySelector ( "#map-cache-layer" ) ;
var layerId = "map-floor-" + floor ;
var layer = cache . querySelector ( "#" + layerId ) ;
if ( ! layer ) {
layer = svgElement ( "g" , { id : layerId , class : "map-floor-layer" } ) ;
state . bootstrap . edges . filter ( function ( edge ) {
return Number ( edge . floor ) === floor ;
} ) . forEach ( function ( edge ) {
drawSegment ( layer , edge . fromSite , edge . toSite , "map-edge" , project ) ;
} ) ;
state . bootstrap . sites . filter ( function ( site ) {
return site . floor === floor ;
} ) . forEach ( function ( site ) {
drawBackgroundNode ( layer , site , project ) ;
} ) ;
cache . appendChild ( layer ) ;
}
cache . querySelectorAll ( ".map-floor-layer" ) . forEach ( function ( candidate ) {
if ( candidate . id === layerId ) candidate . removeAttribute ( "hidden" ) ;
else candidate . setAttribute ( "hidden" , "hidden" ) ;
} ) ;
}
/** 绘制完整地图中的低对比度站点;编号和坐标通过原生 SVG tooltip 查看。 */
function drawBackgroundNode ( layer , site , project ) {
var point = project ( site ) ;
if ( ! point ) return ;
var node = svgElement ( "circle" , {
cx : point . x , cy : point . y , r : 2.4 , class : "map-site"
} ) ;
var title = svgElement ( "title" ) ;
title . textContent = site . siteId + " · " + site . floor + "F · ("
+ site . x + ", " + site . y + ")" ;
node . appendChild ( title ) ;
layer . appendChild ( node ) ;
}
/** 按后端给出的边集合差异叠加共同、仅新规划和仅历史路线。 */
function drawRouteComparison ( layer , task , project ) {
var comparison = task . pathComparison ;
if ( ! comparison ) {
if ( elements [ "show-historical" ] . checked ) {
drawPath ( layer , task . historicalPath || [ ] , "historical" , project ) ;
}
drawPath ( layer , task . plannedPath || [ ] , "service" , project ) ;
return ;
}
drawEvidenceEdges ( layer , comparison . sharedEdges ,
"shared-base planned-only" , project ) ;
drawEvidenceEdges ( layer , comparison . plannedOnlyEdges , "planned-only" , project ) ;
if ( elements [ "show-historical" ] . checked ) {
drawEvidenceEdges ( layer , comparison . sharedEdges ,
"shared-overlay historical-only" , project ) ;
drawEvidenceEdges ( layer , comparison . historicalOnlyEdges ,
"historical-only" , project ) ;
}
}
/** 将证据 DTO 中的端点直接画成线,不在浏览器中重新判断或规划邻接关系。 */
function drawEvidenceEdges ( layer , edges , className , project ) {
( edges || [ ] ) . forEach ( function ( edge ) {
drawSegment ( layer , edge . fromSite , edge . toSite , className , project ) ;
} ) ;
}
/** 返回新旧路线分叉涉及的站点,用于只标注真正需要人工比较的位置。 */
function comparisonNodeIds ( task , includeHistorical ) {
var comparison = task . pathComparison || emptyPathComparison ( ) ;
var incidentTypes = new Map ( ) ;
// 记录每个站点实际连接到哪类证据边,避免把一整段路线的所有中间点都当成分叉点。
function markIncident ( edges , type ) {
( edges || [ ] ) . forEach ( function ( edge ) {
[ edge . fromSite , edge . toSite ] . forEach ( function ( siteId ) {
if ( ! incidentTypes . has ( siteId ) ) incidentTypes . set ( siteId , new Set ( ) ) ;
incidentTypes . get ( siteId ) . add ( type ) ;
} ) ;
} ) ;
}
markIncident ( comparison . sharedEdges , "shared" ) ;
markIncident ( comparison . plannedOnlyEdges , "planned" ) ;
if ( includeHistorical ) markIncident ( comparison . historicalOnlyEdges , "historical" ) ;
var boundaries = [ ] ;
incidentTypes . forEach ( function ( types , siteId ) {
// 两条专属路线在此相遇,或路线从共同路段切出/汇回时,才属于人工审计需要的边界。
var joinsExclusiveRoutes = types . has ( "planned" ) && types . has ( "historical" ) ;
var leavesSharedRoute = types . has ( "shared" )
&& ( types . has ( "planned" ) || types . has ( "historical" ) ) ;
if ( joinsExclusiveRoutes || leavesSharedRoute ) boundaries . push ( siteId ) ;
} ) ;
return new Set ( boundaries ) ;
}
function showMapHint ( title , detail ) {
showEmpty ( title , detail ) ;
elements [ "route-empty" ] . classList . add ( "is-map-hint" ) ;
}
function hideMapHint ( ) {
elements [ "route-empty" ] . hidden = true ;
elements [ "route-empty" ] . classList . remove ( "is-map-hint" ) ;
}
function drawPath ( layer , ids , className , project ) {
@@ -406,11 +573,19 @@
circle . appendChild ( title ) ;
layer . appendChild ( circle ) ;
if ( flags . label ) {
var label = svgElement ( "text" , { x : point . x + 11 , y : point . y - 10 , class : "node-label" } ) ;
// 靠近地图右边界的业务标签改向左展开,路径聚焦时站点号和坐标不会被 SVG 裁掉。
var labelOnLeft = point . x > 900 ;
var labelX = point . x + ( labelOnLeft ? - 11 : 11 ) ;
var textAnchor = labelOnLeft ? "end" : "start" ;
var label = svgElement ( "text" , {
x : labelX , y : point . y - 10 , "text-anchor" : textAnchor , class : "node-label"
} ) ;
label . textContent = site . siteId ;
layer . appendChild ( label ) ;
if ( flags . start || flags . target ) {
var coordinate = svgElement ( "text" , { x : point . x + 11 , y : point . y + 7 , class : "node-coordinate" } ) ;
var coordinate = svgElement ( "text" , {
x : labelX , y : point . y + 7 , "text-anchor" : textAnchor , class : "node-coordinate"
} ) ;
coordinate . textContent = "(" + site . x + ", " + site . y + ")" ;
layer . appendChild ( coordinate ) ;
}
@@ -432,10 +607,158 @@
layer . appendChild ( marker ) ;
}
/** 汇总当前任务的全部证据节点,供“路径”视口计算包围盒。 */
function selectedRouteSiteIds ( ) {
var task = selectedTask ( ) ;
if ( ! task ) return [ ] ;
var ids = [ ] . concat ( task . plannedPath || [ ] , task . historicalPath || [ ] ,
[ task . physicalStartSite , task . physicalEndSite ] ) ;
eventsForTask ( currentResult ( ) , task . simulationTaskId ) . forEach ( function ( event ) {
if ( event . fromSite ) ids . push ( event . fromSite ) ;
if ( event . toSite ) ids . push ( event . toSite ) ;
} ) ;
return unique ( ids ) ;
}
/** 切换完整地图或路径聚焦视口,保持路径与底图使用同一坐标系。 */
function setViewMode ( mode ) {
if ( mode === "path" && selectedTask ( ) ) fitPathView ( ) ;
else resetRouteView ( ) ;
state . routeView . mode = mode === "path" && selectedTask ( ) ? "path" : "map" ;
updateViewModeButtons ( ) ;
applyRouteView ( ) ;
}
/** 任务或楼层改变时按当前模式重新适配;自由缩放视口回到完整地图。 */
function fitCurrentViewMode ( ) {
if ( state . routeView . mode === "path" && selectedTask ( ) ) fitPathView ( ) ;
else resetRouteView ( ) ;
}
/** 恢复当前楼层完整地图边界。 */
function resetRouteView ( ) {
state . routeView = { mode : "map" , x : 0 , y : 0 , width : 1000 , height : 560 } ;
updateViewModeButtons ( ) ;
applyRouteView ( ) ;
}
/** 适配当前楼层上与任务有关的全部节点,并保留周围道路上下文。 */
function fitPathView ( ) {
var project = projectorForFloor ( state . selectedFloor ) ;
var points = selectedRouteSiteIds ( ) . map ( siteById ) . filter ( Boolean )
. map ( project ) . filter ( Boolean ) ;
if ( ! points . length ) {
resetRouteView ( ) ;
return ;
}
var xs = points . map ( function ( point ) { return point . x ; } ) ;
var ys = points . map ( function ( point ) { return point . y ; } ) ;
var minX = Math . min . apply ( Math , xs ) ;
var maxX = Math . max . apply ( Math , xs ) ;
var minY = Math . min . apply ( Math , ys ) ;
var maxY = Math . max . apply ( Math , ys ) ;
var centerX = ( minX + maxX ) / 2 ;
var centerY = ( minY + maxY ) / 2 ;
// 两侧至少保留 64 个 SVG 单位,覆盖 6 位站点号和起终点坐标的实际文字宽度。
var width = Math . max ( 180 , maxX - minX + 128 ) ;
var height = Math . max ( 101 , maxY - minY + 128 ) ;
var ratio = 1000 / 560 ;
if ( width / height > ratio ) height = width / ratio ;
else width = height * ratio ;
width = Math . min ( 1000 , width ) ;
height = Math . min ( 560 , height ) ;
state . routeView = {
mode : "path" ,
x : centerX - width / 2 ,
y : centerY - height / 2 ,
width : width ,
height : height
} ;
clampRouteView ( ) ;
updateViewModeButtons ( ) ;
applyRouteView ( ) ;
}
/** 将内存中的视口写入 SVG; tick 重绘不会调用任何适配逻辑。 */
function applyRouteView ( ) {
var view = state . routeView ;
elements [ "route-map" ] . setAttribute ( "viewBox" ,
[ view . x , view . y , view . width , view . height ] . join ( " " ) ) ;
}
/** 围绕鼠标或画布中心缩放,并限制在完整地图坐标范围内。 */
function zoomRoute ( factor , anchorX , anchorY ) {
var view = state . routeView ;
var nextWidth = Math . max ( 140 , Math . min ( 1000 , view . width * factor ) ) ;
var nextHeight = nextWidth * 560 / 1000 ;
var xRatio = anchorX === undefined ? 0.5 : anchorX ;
var yRatio = anchorY === undefined ? 0.5 : anchorY ;
view . x += ( view . width - nextWidth ) * xRatio ;
view . y += ( view . height - nextHeight ) * yRatio ;
view . width = nextWidth ;
view . height = nextHeight ;
view . mode = "custom" ;
clampRouteView ( ) ;
updateViewModeButtons ( ) ;
applyRouteView ( ) ;
}
function handleRouteWheel ( event ) {
event . preventDefault ( ) ;
var bounds = elements [ "route-map" ] . getBoundingClientRect ( ) ;
zoomRoute ( event . deltaY > 0 ? 1.12 : 0.89 ,
( event . clientX - bounds . left ) / bounds . width ,
( event . clientY - bounds . top ) / bounds . height ) ;
}
function beginRouteDrag ( event ) {
if ( event . button !== 0 ) return ;
event . preventDefault ( ) ;
state . routeDrag = { x : event . clientX , y : event . clientY } ;
state . routeView . mode = "custom" ;
updateViewModeButtons ( ) ;
elements [ "route-map" ] . classList . add ( "is-dragging" ) ;
elements [ "route-map" ] . setPointerCapture ( event . pointerId ) ;
}
function continueRouteDrag ( event ) {
if ( ! state . routeDrag ) return ;
var bounds = elements [ "route-map" ] . getBoundingClientRect ( ) ;
state . routeView . x -= ( event . clientX - state . routeDrag . x )
* state . routeView . width / bounds . width ;
state . routeView . y -= ( event . clientY - state . routeDrag . y )
* state . routeView . height / bounds . height ;
state . routeDrag = { x : event . clientX , y : event . clientY } ;
clampRouteView ( ) ;
applyRouteView ( ) ;
}
function endRouteDrag ( event ) {
if ( ! state . routeDrag ) return ;
state . routeDrag = null ;
elements [ "route-map" ] . classList . remove ( "is-dragging" ) ;
if ( event && elements [ "route-map" ] . hasPointerCapture ( event . pointerId ) ) {
elements [ "route-map" ] . releasePointerCapture ( event . pointerId ) ;
}
}
/** 避免缩放或拖拽后视口离开 1000x560 的完整地图画布。 */
function clampRouteView ( ) {
var view = state . routeView ;
view . x = Math . max ( 0 , Math . min ( 1000 - view . width , view . x ) ) ;
view . y = Math . max ( 0 , Math . min ( 560 - view . height , view . y ) ) ;
}
function updateViewModeButtons ( ) {
elements [ "map-fit" ] . classList . toggle ( "is-active" , state . routeView . mode === "map" ) ;
elements [ "path-fit" ] . classList . toggle ( "is-active" , state . routeView . mode === "path" ) ;
}
function renderTimeline ( ) {
elements [ "timeline-slider" ] . value = String ( state . tickIndex ) ;
elements [ "tick-readout" ] . textContent = "tick " + tickValue ( ) ;
renderRoute ( ) ;
populateFloors ( ) ;
renderEventTable ( ) ;
}
@@ -746,6 +1069,7 @@
}
function showEmpty ( title , detail ) {
elements [ "route-empty" ] . classList . remove ( "is-map-hint" ) ;
elements [ "route-empty" ] . hidden = false ;
var strong = elements [ "route-empty" ] . querySelector ( "strong" ) ;
var span = elements [ "route-empty" ] . querySelector ( "span" ) ;
@@ -756,7 +1080,8 @@
function resetInspector ( ) {
[ "inspector-task-id" , "evidence-endpoints" , "evidence-vehicle" , "evidence-history-vehicle" ,
"evidence-assigned-time" , "evidence-waiting" , "evidence-completion" ,
"evidence-hold-retry" , "evidence-plc" , "planned-path-output" , "reason-output " ]
"evidence-hold-retry" , "evidence-plc" , "route-difference " ,
"invalid-history-segments" , "planned-path-output" , "reason-output" ]
. forEach ( function ( id ) { setText ( id , "--" ) ; } ) ;
setTaskStatus ( "--" ) ;
}
@@ -835,6 +1160,22 @@
return value === null || value === undefined ? "--" : "T+" + formatDuration ( value ) ;
}
function emptyPathComparison ( ) {
return {
sharedEdges : [ ] , plannedOnlyEdges : [ ] , historicalOnlyEdges : [ ] ,
invalidHistoricalSegments : [ ] , sharedEdgeCount : 0 ,
plannedOnlyEdgeCount : 0 , historicalOnlyEdgeCount : 0
} ;
}
function formatInvalidSegments ( segments ) {
if ( ! segments || ! segments . length ) return "0" ;
return segments . length + " 段:" + segments . map ( function ( segment ) {
return ( segment . fromSite || "--" ) + " → " + ( segment . toSite || "--" )
+ " (" + segment . reason + ")" ;
} ) . join ( "; " ) ;
}
function setText ( id , value ) { elements [ id ] . textContent = value ; }
function shortHash ( value ) { return value ? value . slice ( 0 , 10 ) : "--" ; }
function manhattan ( left , right ) { return Math . abs ( left . x - right . x ) + Math . abs ( left . y - right . y ) ; }