@@ -47,7 +47,6 @@
. topology-node { cursor : pointer ; outline : none ; }
. topology-node rect { vector-effect : non-scaling-stroke ; transition : filter 150 ms ease , stroke-width 150 ms ease ; }
. topology-node : hover rect , . topology-node : focus-visible rect { filter : brightness ( 1.25 ) ; stroke-width : 5 ; }
. topology-node . hidden { display : none ; }
. node-card { fill : #10263a ; stroke : var ( - - habitat ) ; stroke-width : 3 ; }
. core-card { fill : #3b250d ; stroke : var ( - - core ) ; stroke-width : 5 ; }
. power-card { fill : #2a2810 ; stroke : var ( - - power ) ; stroke-width : 3 ; }
@@ -97,9 +96,9 @@
< option value = "" > (最新)< / option >
< / select >
< label for = "time-input" > 时间:< / label >
< input type = "text" id = "time-input" placeholder = "ISO 8601, 如 2024-09-01T00:00:00Z" size = "28" >
< input type = "text" id = "time-input" placeholder = "ISO 8601, 如 2024-09-01T00:00:00Z" size = "28" >
< button id = "go-btn" > 查询< / button >
< span style = "color:var(--muted);font-size:0.82rem; " id = "snapshot-info" > < / span >
< span class = "snapshot-info " id = "snapshot-info" > < / span >
< / div >
< div class = "legend" aria-label = "图例" >
@@ -115,123 +114,13 @@
< div class = "map-shell" id = "map-shell" style = "display:none;" >
< svg class = "topology-map" viewBox = "0 0 1500 1900" role = "img" aria-labelledby = "topology-title" id = "topology-svg" >
< title id = "topology-title" > 广寒基地拓扑< / title >
< desc id = "topology-desc" > 核心舱四向安装四个舱段,南侧经居住舱一号连接电力模块十字结构 。< / desc >
<!-- Structural: core to modules (N/E/S/W) -->
< line class = "connection structural" x1 = "750" y1 = "360" x2 = "750" y2 = "470" data-conn = "core-structural" data-link = "core_cabin.north-habitat_2.core" / >
< line class = "connection structural" x1 = "550" y1 = "570" x2 = "630" y2 = "570" data-conn = "core-structural" data-link = "core_cabin.west-greenhouse.core" / >
< line class = "connection structural" x1 = "870" y1 = "570" x2 = "950" y2 = "570" data-conn = "core-structural" data-link = "core_cabin.east-lab_module.core" / >
< line class = "connection structural" x1 = "750" y1 = "670" x2 = "750" y2 = "760" data-conn = "core-structural" data-link = "core_cabin.south-habitat_1.core" / >
<!-- Pressurized: exactly two -->
< line class = "connection pressurized" x1 = "250" y1 = "570" x2 = "330" y2 = "570" data-conn = "pressurized" data-link = "wugang_2.dock-greenhouse.distal" / >
< line class = "connection pressurized" x1 = "750" y1 = "880" x2 = "750" y2 = "990" data-conn = "pressurized" data-link = "habitat_1.distal-power_module_hub.north" / >
<!-- Core cross nodes -->
< g class = "topology-node" data-module = "habitat-2" data-comp = "habitat_2" role = "button" tabindex = "0" >
< rect class = "node-card" x = "630" y = "220" width = "240" height = "140" rx = "16" / >
< text class = "node-title" x = "750" y = "278" > 居住舱#2< / text >
< text class = "node-meta" x = "750" y = "315" > 核心舱北部安装口< / text >
< text class = "node-hint" x = "750" y = "340" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "wugang-2" data-comp = "wugang_2" role = "button" tabindex = "0" >
< rect class = "vehicle-card" x = "40" y = "495" width = "210" height = "150" rx = "16" / >
< text class = "node-title" x = "145" y = "554" > 吴刚二号< / text >
< text class = "node-meta" x = "145" y = "591" > Block 2 载人着陆器< / text >
< text class = "node-hint" x = "145" y = "619" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "greenhouse" data-comp = "greenhouse" role = "button" tabindex = "0" >
< rect class = "node-card" x = "330" y = "490" width = "220" height = "160" rx = "16" / >
< text class = "node-title" x = "440" y = "548" > 温室模块< / text >
< text class = "node-meta" x = "440" y = "585" > 核心舱西部安装口< / text >
< text class = "node-hint" x = "440" y = "617" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "core" data-comp = "core_cabin" role = "button" tabindex = "0" >
< rect class = "core-card" x = "630" y = "470" width = "240" height = "200" rx = "18" / >
< text class = "node-title" x = "750" y = "545" > 广寒核心舱< / text >
< text class = "node-meta" x = "750" y = "585" > 四向安装节点< / text >
< text class = "node-hint" x = "750" y = "620" > 加载中...< / text >
< text class = "node-hint" x = "750" y = "646" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "laboratory" data-comp = "lab_module" role = "button" tabindex = "0" >
< rect class = "node-card" x = "950" y = "490" width = "220" height = "160" rx = "16" / >
< text class = "node-title" x = "1060" y = "548" > 实验舱< / text >
< text class = "node-meta" x = "1060" y = "585" > 核心舱东部安装口< / text >
< text class = "node-hint" x = "1060" y = "617" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "habitat-1" data-comp = "habitat_1" role = "button" tabindex = "0" >
< rect class = "node-card" x = "630" y = "760" width = "240" height = "120" rx = "16" / >
< text class = "node-title" x = "750" y = "808" > 居住舱#1< / text >
< text class = "node-meta" x = "750" y = "846" > 核心舱南部安装口< / text >
< / g >
<!-- Power structural connections -->
< line class = "connection structural" x1 = "750" y1 = "1150" x2 = "750" y2 = "1260" data-conn = "power-structural" data-link = "power_module_hub.north-power_storage_rack.mount" / >
< line class = "connection structural" x1 = "540" y1 = "1350" x2 = "650" y2 = "1350" data-conn = "power-structural" data-link = "power_module_hub.west" / >
< line class = "connection structural" x1 = "850" y1 = "1350" x2 = "960" y2 = "1350" data-conn = "power-structural" data-link = "power_module_hub.east" / >
< line class = "connection structural" x1 = "750" y1 = "1440" x2 = "750" y2 = "1540" data-conn = "power-structural" data-link = "power_module_hub.south-nuclear_reactor.mount" / >
<!-- Utility: binglun fuel/power -->
< line class = "connection utility" x1 = "240" y1 = "1350" x2 = "280" y2 = "1350" data-conn = "utility-west" / >
< line class = "connection utility" x1 = "750" y1 = "1690" x2 = "750" y2 = "1740" data-conn = "utility-south" / >
< line class = "connection utility" x1 = "1220" y1 = "1350" x2 = "1260" y2 = "1350" data-conn = "utility-east" / >
<!-- Radiators -->
< g class = "radiator" > < rect x = "820" y = "972" width = "130" height = "16" rx = "3" / > < line x1 = "850" y1 = "973" x2 = "850" y2 = "987" / > < line x1 = "885" y1 = "973" x2 = "885" y2 = "987" / > < line x1 = "920" y1 = "973" x2 = "920" y2 = "987" / > < text class = "radiator-label" x = "885" y = "964" > 散热器< / text > < / g >
< g class = "radiator" > < rect x = "345" y = "1232" width = "130" height = "16" rx = "3" / > < line x1 = "375" y1 = "1233" x2 = "375" y2 = "1247" / > < line x1 = "410" y1 = "1233" x2 = "410" y2 = "1247" / > < line x1 = "445" y1 = "1233" x2 = "445" y2 = "1247" / > < text class = "radiator-label" x = "410" y = "1224" > 散热器< / text > < / g >
< g class = "radiator" > < rect x = "1025" y = "1232" width = "130" height = "16" rx = "3" / > < line x1 = "1055" y1 = "1233" x2 = "1055" y2 = "1247" / > < line x1 = "1090" y1 = "1233" x2 = "1090" y2 = "1247" / > < line x1 = "1125" y1 = "1233" x2 = "1125" y2 = "1247" / > < text class = "radiator-label" x = "1090" y = "1224" > 散热器< / text > < / g >
< g class = "radiator" > < rect x = "790" y = "1518" width = "130" height = "16" rx = "3" / > < line x1 = "820" y1 = "1519" x2 = "820" y2 = "1533" / > < line x1 = "855" y1 = "1519" x2 = "855" y2 = "1533" / > < line x1 = "890" y1 = "1519" x2 = "890" y2 = "1533" / > < text class = "radiator-label" x = "855" y = "1510" > 散热器< / text > < / g >
<!-- Power cross nodes -->
< g class = "topology-node" data-module = "power-north" data-comp = "power_storage_rack" role = "button" tabindex = "0" >
< rect class = "power-card" x = "600" y = "990" width = "300" height = "160" rx = "16" / >
< text class = "node-title" x = "750" y = "1044" > 北部模块< / text >
< text class = "node-meta" x = "750" y = "1082" > 电力模块存储支架< / text >
< text class = "node-hint" x = "750" y = "1115" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "power-west" data-comp = "power_west_group" role = "button" tabindex = "0" >
< rect class = "power-card" x = "280" y = "1250" width = "260" height = "200" rx = "16" / >
< text class = "node-title" x = "410" y = "1310" > 西部模块< / text >
< text class = "node-meta" x = "410" y = "1348" > 加载中...< / text >
< text class = "node-hint" x = "410" y = "1382" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "power-node" data-comp = "power_module_hub" role = "button" tabindex = "0" >
< rect class = "power-node-card" x = "650" y = "1260" width = "200" height = "180" rx = "18" / >
< text class = "node-title" x = "750" y = "1325" > 电力模块< / text >
< text class = "node-meta" x = "750" y = "1363" > 四向安装节点< / text >
< text class = "node-hint" x = "750" y = "1397" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "power-east" data-comp = "power_east_group" role = "button" tabindex = "0" >
< rect class = "power-card" x = "960" y = "1250" width = "260" height = "200" rx = "16" / >
< text class = "node-title" x = "1090" y = "1310" > 东部模块< / text >
< text class = "node-meta" x = "1090" y = "1348" > 加载中...< / text >
< text class = "node-hint" x = "1090" y = "1382" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "power-south" data-comp = "nuclear_reactor" role = "button" tabindex = "0" >
< rect class = "power-card" x = "620" y = "1540" width = "260" height = "150" rx = "16" / >
< text class = "node-title" x = "750" y = "1600" > 南部模块< / text >
< text class = "node-meta" x = "750" y = "1640" > 核反应堆< / text >
< text class = "node-hint" x = "750" y = "1668" > 加载中...< / text >
< / g >
<!-- Bingluns -->
< g class = "topology-node" data-module = "binglun-1" data-comp = "binglun_1" role = "button" tabindex = "0" >
< rect class = "vehicle-card" x = "30" y = "1280" width = "210" height = "140" rx = "16" / >
< text class = "node-title" x = "135" y = "1335" > 冰轮一号< / text >
< text class = "node-meta" x = "135" y = "1372" > 加载中...< / text >
< text class = "node-hint" x = "135" y = "1400" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "binglun-2" data-comp = "binglun_2" role = "button" tabindex = "0" >
< rect class = "vehicle-card" x = "645" y = "1740" width = "210" height = "120" rx = "16" / >
< text class = "node-title" x = "750" y = "1790" > 冰轮二号< / text >
< text class = "node-meta" x = "750" y = "1828" > 加载中...< / text >
< / g >
< g class = "topology-node" data-module = "binglun-3" data-comp = "binglun_3" role = "button" tabindex = "0" >
< rect class = "vehicle-card" x = "1260" y = "1280" width = "210" height = "140" rx = "16" / >
< text class = "node-title" x = "1365" y = "1335" > 冰轮三号< / text >
< text class = "node-meta" x = "1365" y = "1372" > 加载中...< / text >
< text class = "node-hint" x = "1365" y = "1400" > 加载中...< / text >
< / g >
< desc id = "topology-desc" > 根据所选任务或时间展示广寒基地当时的工程拓扑 。< / desc >
<!-- FUTURE_EXPANSION_ANCHOR:north -->
<!-- FUTURE_EXPANSION_ANCHOR:east -->
<!-- FUTURE_EXPANSION_ANCHOR:west -->
< g id = "connection-layer" > < / g >
< g id = "decoration-layer" > < / g >
< g id = "node-layer" > < / g >
< / svg >
< / div >
< p class = "click-note" > 模块与车辆支持详情查看;走道、管线和散热面板仅用于表达拓扑关系。< / p >
@@ -256,202 +145,313 @@
< script >
const API _BASE = '' ;
const SVG _NS = 'http://www.w3.org/2000/svg' ;
const TYPE _LABELS = { module : '舱段模块' , node : '结构节点' , vehicle : '着陆器/车辆' , equipment : '设备模块' } ;
let currentData = null ;
// Group node members for composite SVG elements
const GROUP _MEMBERS = {
power _west _group : [ 'isru_module' , 'mining_module' , 'ore_fuel_storage' ] ,
power _east _group : [ 'fuel_storage_mod' , 'centrifuge_mod' ] ,
} ;
const TYPE _LABELS = { module : '舱段模块' , node : '结构节点' , vehicle : '着陆器/车辆' , equipment : '设备模块' } ;
function svgElement ( name , attributes = { } ) {
const element = document . createElementNS ( SVG _NS , name ) ;
Object . entries ( attributes ) . forEach ( ( [ key , value ] ) => {
if ( value !== null && value !== undefined ) element . setAttribute ( key , value ) ;
} ) ;
return element ;
}
async function fetchSnapshot ( mission , at ) {
const params = new URLSearchParams ( ) ;
if ( at ) { params . set ( 'at' , at ) ; }
else if ( mission ) { params . set ( 'mission' , mission ) ; }
const url = ` ${ API _BASE } /api/v1/bases/guanghan/snapshot? ${ params } ` ;
const res = await fetch ( url ) ;
if ( ! res . ok ) {
const err = await res . json ( ) ;
throw new Error ( err . message || ` HTTP ${ res . status } ` ) ;
if ( at ) params . set ( 'at' , at ) ;
else if ( mission ) params . set ( 'mission' , mission ) ;
const response = await fetch ( ` ${ API _BASE } /api/v1/bases/guanghan/snapshot? ${ params } ` ) ;
if ( ! response . ok ) {
const error = await response . json ( ) ;
throw new Error ( error . message || ` HTTP ${ response . status } ` ) ;
}
return res . json ( ) ;
return response . json ( ) ;
}
async function loadMissions ( ) {
// Hardcoded mission list for the dropdown
return [
'BL-01' , 'GHC-01' , 'JC-01' , 'BL-02' , 'GHC-02' , 'GHC-03' , 'BL-03' , 'GHC-04' , 'GHC-05'
] ;
return [ 'BL-01' , 'GHC-01' , 'JC-01' , 'BL-02' , 'GHC-02' , 'GHC-03' , 'BL-03' , 'GHC-04' , 'GHC-05' ] ;
}
function getExistingComponentKeys ( ) {
if ( ! currentData ) return new Set ( ) ;
return new Set ( currentData . components . map ( c => c . key ) ) ;
function splitEndpoint ( endpoint ) {
const separator = endpoint . indexOf ( '.' ) ;
return separator === - 1 ? endpoint : endpoint . slice ( 0 , separator ) ;
}
function isGroupNodeVisible ( groupKey ) {
const members = GROUP _MEMBERS [ groupKey ] ;
if ( ! members ) return true ;
const existing = getExistingComponentKeys ( ) ;
return members . some ( m => existing . has ( m ) ) ;
function connectionClass ( type ) {
if ( type === 'structural_mount' ) return 'structural' ;
if ( type === 'pressurized_passage' ) return 'pressurized' ;
return 'utility' ;
}
function updateNodeVisibility ( ) {
if ( ! currentData ) return ;
const existing = getExistingComponentKeys ( ) ;
document . querySelectorAll ( '.topology-node[data-comp]' ) . forEach ( node => {
const compKey = node . dataset . comp ;
let visible ;
if ( GROUP _MEMBERS [ compKey ] ) {
visible = isGroupNodeVisible ( compKey ) ;
} else {
visible = existing . has ( compKey ) ;
function edgePoint ( source , target ) {
const sourceCenter = {
x : source . x + source . width / 2 ,
y : source . y + source . height / 2 ,
} ;
const targetCenter = {
x : target . x + target . width / 2 ,
y : target . y + target . height / 2 ,
} ;
const dx = targetCenter . x - sourceCenter . x ;
const dy = targetCenter . y - sourceCenter . y ;
if ( dx === 0 && dy === 0 ) return sourceCenter ;
const xScale = dx === 0 ? Infinity : ( source . width / 2 ) / Math . abs ( dx ) ;
const yScale = dy === 0 ? Infinity : ( source . height / 2 ) / Math . abs ( dy ) ;
const scale = Math . min ( xScale , yScale ) ;
return {
x : sourceCenter . x + dx * scale ,
y : sourceCenter . y + dy * scale ,
} ;
}
function fitSvgText ( textElement , fullText , maxWidth ) {
textElement . textContent = fullText || '' ;
if ( ! fullText ) return ;
if ( textElement . getComputedTextLength ( ) <= maxWidth ) return ;
let fitted = fullText ;
while ( fitted . length > 1 ) {
fitted = fitted . slice ( 0 , - 1 ) ;
textElement . textContent = ` ${ fitted } … ` ;
if ( textElement . getComputedTextLength ( ) <= maxWidth ) break ;
}
node . classList . toggle ( 'hidden' , ! visible ) ;
}
function appendNodeText ( group , node , className , text , y ) {
if ( ! text ) return ;
const element = svgElement ( 'text' , {
class : className ,
x : node . x + node . width / 2 ,
y ,
} ) ;
// Hide/show connections
const connTypes = { 'core-structural' : [ 'core_cabin' ] , 'pressurized' : [ 'habitat_1' , 'greenhouse' , 'wugang_2' ] , 'power-structural' : [ 'power_module_hub' ] , 'utility-west' : [ 'binglun_1' ] , 'utility-south' : [ 'binglun_2' ] , 'utility-east' : [ 'binglun_3' ] } ;
document . querySelectorAll ( '.connection[data-conn]' ) . forEach ( line => {
const conn = line . dataset . conn ;
const needed = connTypes [ conn ] || [ ] ;
const visible = needed . length === 0 || needed . some ( k => existing . has ( k ) ) ;
line . style . display = visible ? '' : 'none' ;
group . appendChild ( element ) ;
fitSvgText ( element , text , node . width - 24 ) ;
}
function createRadiator ( node ) {
const group = svgElement ( 'g' , { class : 'radiator' , 'aria-label' : '大型折叠散热面板' } ) ;
const width = Math . min ( 130 , node . width * 0.55 ) ;
const x = node . x + node . width * 0.55 ;
const y = node . y - 18 ;
group . appendChild ( svgElement ( 'rect' , { x , y , width , height : 16 , rx : 3 } ) ) ;
for ( let index = 1 ; index < 4 ; index += 1 ) {
const lineX = x + width * index / 4 ;
group . appendChild ( svgElement ( 'line' , { x1 : lineX , y1 : y + 1 , x2 : lineX , y2 : y + 15 } ) ) ;
}
const label = svgElement ( 'text' , {
class : 'radiator-label' ,
x : x + width / 2 ,
y : y - 8 ,
} ) ;
label . textContent = '散热器' ;
group . appendChild ( label ) ;
return group ;
}
function renderTopology ( ) {
const payload = currentData . layout ;
if ( ! payload || ! payload . layout ) throw new Error ( '拓扑布局资料未记录' ) ;
const svg = document . getElementById ( 'topology-svg' ) ;
svg . setAttribute ( 'viewBox' , ` 0 0 ${ payload . layout . canvasWidth } ${ payload . layout . canvasHeight } ` ) ;
const connectionLayer = document . getElementById ( 'connection-layer' ) ;
const decorationLayer = document . getElementById ( 'decoration-layer' ) ;
const nodeLayer = document . getElementById ( 'node-layer' ) ;
connectionLayer . replaceChildren ( ) ;
decorationLayer . replaceChildren ( ) ;
nodeLayer . replaceChildren ( ) ;
const componentMap = new Map ( currentData . components . map ( component => [ component . key , component ] ) ) ;
const visibleNodes = payload . nodes
. filter ( node => node . memberKeys . some ( key => componentMap . has ( key ) ) ) ;
const nodeByKey = new Map ( visibleNodes . map ( node => [ node . nodeKey , node ] ) ) ;
const nodeByComponent = new Map ( ) ;
visibleNodes . forEach ( node => {
node . memberKeys . forEach ( key => {
if ( componentMap . has ( key ) ) nodeByComponent . set ( key , node ) ;
} ) ;
} ) ;
const visualConnections = new Map ( ) ;
currentData . connections . forEach ( connection => {
const fromComponent = splitEndpoint ( connection . fromPort ) ;
const toComponent = splitEndpoint ( connection . toPort ) ;
const fromNode = nodeByComponent . get ( fromComponent ) ;
const toNode = nodeByComponent . get ( toComponent ) ;
if ( ! fromNode || ! toNode || fromNode . nodeKey === toNode . nodeKey ) return ;
const style = connectionClass ( connection . type ) ;
const pair = [ fromNode . nodeKey , toNode . nodeKey ] . sort ( ) ;
const key = ` ${ pair [ 0 ] } | ${ pair [ 1 ] } | ${ style } ` ;
if ( ! visualConnections . has ( key ) ) {
visualConnections . set ( key , { fromNode , toNode , style , connection } ) ;
}
} ) ;
visualConnections . forEach ( ( { fromNode , toNode , style , connection } ) => {
const start = edgePoint ( fromNode , toNode ) ;
const end = edgePoint ( toNode , fromNode ) ;
connectionLayer . appendChild ( svgElement ( 'line' , {
class : ` connection ${ style } ` ,
x1 : start . x ,
y1 : start . y ,
x2 : end . x ,
y2 : end . y ,
'data-type' : connection . type ,
'data-from' : fromNode . nodeKey ,
'data-to' : toNode . nodeKey ,
} ) ) ;
} ) ;
visibleNodes . forEach ( node => {
if ( node . decorations && node . decorations . radiator ) {
decorationLayer . appendChild ( createRadiator ( node ) ) ;
}
} ) ;
[ ... visibleNodes ]
. sort ( ( left , right ) => left . zIndex - right . zIndex )
. forEach ( node => {
const components = node . memberKeys . map ( key => componentMap . get ( key ) ) . filter ( Boolean ) ;
const primary = components [ 0 ] ;
const title = node . labelOverride || primary . name ;
const group = svgElement ( 'g' , {
class : 'topology-node' ,
'data-node-key' : node . nodeKey ,
'data-comp' : node . nodeKey ,
role : 'button' ,
tabindex : 0 ,
} ) ;
const fullTitle = svgElement ( 'title' ) ;
fullTitle . textContent = title ;
group . appendChild ( fullTitle ) ;
group . appendChild ( svgElement ( 'rect' , {
class : node . styleKey ,
x : node . x ,
y : node . y ,
width : node . width ,
height : node . height ,
rx : 16 ,
} ) ) ;
const hasHint = Boolean ( node . displayHint ) ;
appendNodeText ( group , node , 'node-title' , title , node . y + node . height * ( hasHint ? 0.34 : 0.42 ) ) ;
appendNodeText ( group , node , 'node-meta' , node . displayMeta , node . y + node . height * ( hasHint ? 0.60 : 0.72 ) ) ;
appendNodeText ( group , node , 'node-hint' , node . displayHint , node . y + node . height * 0.82 ) ;
group . addEventListener ( 'click' , ( ) => openModal ( node . nodeKey ) ) ;
group . addEventListener ( 'keydown' , event => {
if ( event . key === 'Enter' || event . key === ' ' ) {
event . preventDefault ( ) ;
openModal ( node . nodeKey ) ;
}
} ) ;
nodeLayer . appendChild ( group ) ;
} ) ;
}
function updateNodeLabels ( ) {
if ( ! currentData ) return ;
const compMap = { } ;
currentData . components . forEach ( c => { compMap [ c . key ] = c ; } ) ;
document . querySelectorAll ( '.topology-node[data-comp]' ) . forEach ( node => {
const compKey = node . dataset . comp ;
let comp ;
if ( GROUP _MEMBERS [ compKey ] ) {
const members = GROUP _MEMBERS [ compKey ] . map ( k => compMap [ k ] ) . filter ( Boolean ) ;
if ( members . length > 0 ) comp = members [ 0 ] ;
} else {
comp = compMap [ compKey ] ;
}
if ( ! comp ) return ;
// Update hint texts
const hints = node . querySelectorAll ( '.node-hint' ) ;
if ( hints . length > 0 && comp . summary ) {
hints [ 0 ] . textContent = comp . summary ;
}
const states = comp . states || { } ;
if ( states . operation === 'operational' && states . operationDetails && states . operationDetails . powerPercent ) {
if ( hints . length > 1 ) hints [ 1 ] . textContent = ` 功率: ${ states . operationDetails . powerPercent } % ` ;
}
// Update meta
const metas = node . querySelectorAll ( '.node-meta' ) ;
if ( metas . length > 0 && comp . type ) {
metas [ 0 ] . textContent = TYPE _LABELS [ comp . type ] || comp . type ;
}
} ) ;
function getNodeComponents ( nodeKey ) {
if ( ! currentData || ! currentData . layout ) return [ ] ;
const node = currentData . layout . nodes . find ( item => item . nodeKey === nodeKey ) ;
if ( ! node ) return [ ] ;
const componentMap = new Map ( currentData . components . map ( component => [ component . key , component ] ) ) ;
return node . memberKeys . map ( key => componentMap . get ( key ) ) . filter ( Boolean ) ;
}
function buildConnectionsForComponent ( compKey ) {
if ( ! currentData ) return [ ] ;
function getLayoutNode ( nodeKey ) {
return currentData ? . layout ? . nodes . find ( node => node . nodeKey === nodeKey ) ;
}
function buildConnectionsForComponent ( componentKey ) {
const result = [ ] ;
currentData . connections . forEach ( conn => {
const [ fromComp , fromPort ] = conn . fromPort . split ( '.' ) ;
const [ toComp , toPort ] = conn . toPort . split ( '.' ) ;
if ( fromComp === compKey || toComp === compKey ) {
const typeLabel = { structural _mount : '结构直连' , pressurized _passage : '加压走道' , fuel : '燃料管线' , power : '供电管线' , docking : '对接' } [ conn . type ] || conn . type ;
const other = fromComp === compKey ? toComp : fromComp ;
const otherComp = currentData . components . find ( c => c . key === other ) ;
const otherName = otherComp ? otherComp . name : other ;
const dir = fromComp === compKey ? '→' : '← ' ;
result . push ( ` ${ dir } ${ otherName } ( ${ typeLabel } ) ` ) ;
}
currentData . connections . forEach ( connection => {
const fromComponent = splitEndpoint ( connection . fromPort ) ;
const toComponent = splitEndpoint ( connection . toPort ) ;
if ( fromComponent ! == componentKey && toComponent ! == componentKey ) return ;
const labels = {
structural _mount : '结构直连' ,
pressurized _passage : '加压走道' ,
fuel : '燃料管线' ,
power : '供电管线 ' ,
docking : '对接' ,
cooling : '冷却回路' ,
} ;
const otherKey = fromComponent === componentKey ? toComponent : fromComponent ;
const other = currentData . components . find ( component => component . key === otherKey ) ;
result . push ( ` ${ other ? . name || otherKey } ( ${ labels [ connection . type ] || connection . type } ) ` ) ;
} ) ;
return result ;
}
function openModal ( compKey ) {
if ( ! currentData ) return ;
const compMap = { } ;
currentData . components . forEach ( c => { compMap [ c . key ] = c ; } ) ;
function openModal ( nodeKey ) {
const components = getNodeComponents ( nodeKey ) ;
if ( components . length === 0 ) return ;
const primary = components [ 0 ] ;
const layoutNode = getLayoutNode ( nodeKey ) ;
document . getElementById ( 'detail-type' ) . textContent =
components . length > 1 ? '组合模块' : ( TYPE _LABELS [ primary . type ] || primary . type ) ;
document . getElementById ( 'detail-title' ) . textContent =
layoutNode ? . labelOverride || primary . name ;
document . getElementById ( 'detail-role' ) . textContent =
components . map ( component => component . description || component . summary || component . name ) . join ( '; ' ) ;
let comps = [ ] ;
if ( GROUP _MEMBERS [ compKey ] ) {
comps = GROUP _MEMBERS [ compKey ] . map ( k => compMap [ k ] ) . filter ( Boolean ) ;
} else if ( compMap [ compKey ] ) {
comps = [ compMap [ compKey ] ] ;
}
if ( comps . length === 0 ) return ;
const ports = components . flatMap ( component =>
( component . ports || [ ] ) . map ( port => ` ${ component . name } : ${ port . displayName } ( ${ port . direction || '—' } ) ` )
) ;
document . getElementById ( 'detail-location' ) . textContent = ports . join ( ' · ' ) || '资料未记录' ;
const primary = comps [ 0 ] ;
const typeLabel = TYPE _LABELS [ primary . type ] || primary . type ;
document . getElementById ( 'detail-type' ) . textContent = typeLabel ;
document . getElementById ( 'detail-title' ) . textContent = primary . name ;
document . getElementById ( 'detail-role' ) . textContent = primary . description || primary . summary || '暂无描述' ;
// Location
const ports = primary . ports || [ ] ;
const portInfo = ports . map ( p => ` ${ p . displayName } ( ${ p . direction || '—' } ) ` ) . join ( ' · ' ) ;
document . getElementById ( 'detail-location' ) . textContent = portInfo || '资料未记录' ;
// Status
const states = primary . states || { } ;
const statusParts = [ ] ;
if ( states . lifecycle ) statusParts . push ( states . lifecycle === 'installed' ? '已安装' : states . lifecycle ) ;
components . forEach ( component => {
const states = component . states || { } ;
const values = [ ] ;
if ( states . lifecycle ) values . push ( states . lifecycle === 'installed' ? '已安装' : states . lifecycle ) ;
if ( states . operation ) {
const opLabel = { operational : '运行中' , standby : '待机' , offline : '离线' } [ states . operation ] || states . operation ;
statusParts . push ( opLabel ) ;
if ( states . operationDetails && states . operationDetails . powerPercent ) {
statusParts . push ( ` 功率 ${ states . operationDetails . powerPercent } % ` ) ;
values . push ( { operational : '运行中' , standby : '待机' , offline : '离线' } [ states . operation ] || states . operation ) ;
if ( states . operationDetails ? . powerPercent ) values . push ( ` 功率 ${ states . operationDetails . powerPercent } % ` ) ;
}
}
document . getElementById ( 'detail-status' ) . textContent = statusParts . join ( ' · ' ) || '资料未记录' ;
// Metrics
const dims = primary . dimensions || { } ;
const props = primary . properties || { } ;
const metrics = [ ] ;
if ( dims . massT ) metrics . push ( ` 质量: ${ dims . massT } t ` ) ;
if ( props . capacity ) metrics . push ( ` 铺位: ${ props . capacity } ` ) ;
if ( props . sleepBags ) metrics . push ( ` 睡袋: ${ props . sleepBags } ` ) ;
if ( props . zones ) metrics . push ( ` 温区: ${ props . zones } ` ) ;
if ( props . foldingArms ) metrics . push ( ` 折叠臂: ${ props . foldingArms } ` ) ;
if ( props . block ) metrics . push ( ` 构型: Block ${ props . block } ` ) ;
if ( props . dryMassT ) metrics . push ( ` 干重: ${ props . dryMassT } t ` ) ;
if ( props . batteryKwh ) metrics . push ( ` 蓄电池: ${ props . batteryKwh } kWh ` ) ;
if ( props . dockingNodes ) metrics . push ( ` 安装节点: ${ props . dockingNodes } ` ) ;
if ( metrics . length === 0 && comps . length > 1 ) {
comps . forEach ( c => { if ( c . summary ) metrics . push ( ` ${ c . name } : ${ c . summary } ` ) ; } ) ;
}
document . getElementById ( 'detail-metrics' ) . innerHTML = metrics . map ( m => ` <span> ${ m } </span> ` ) . join ( '' ) || '<span>资料未记录</span>' ;
// Dimensions
const dimLines = [ ] ;
if ( dims . lengthM ) dimLines . push ( ` 长: ${ dims . lengthM } m ` ) ;
if ( dims . widthM ) dimLines . push ( ` 宽: ${ dims . widthM } m ` ) ;
if ( dims . heightM ) dimLines . push ( ` 高: ${ dims . heightM } m ` ) ;
if ( dims . diameterM ) dimLines . push ( ` 直径: ${ dims . diameterM } m ` ) ;
if ( props . transportHeightM ) dimLines . push ( ` 运输: ${ props . transportHeightM } × ${ props . transportLengthM } × ${ props . transportWidthM } m ` ) ;
document . getElementById ( 'detail-dimensions' ) . innerHTML = dimLines . map ( l => ` <span> ${ l } </span> ` ) . join ( '' ) || '<span>资料未记录</span>' ;
// Connections
const conns = buildConnectionsForComponent ( compKey ) ;
if ( comps . length > 1 ) {
comps . slice ( 1 ) . forEach ( c => {
buildConnectionsForComponent ( c . key ) . forEach ( cc => { if ( ! conns . includes ( cc ) ) conns . push ( cc ) ; } ) ;
if ( values . length ) statusParts . push ( ` ${ component . name } : ${ values . join ( ' · ' ) } ` ) ;
} ) ;
}
document . getElementById ( 'detail-connections' ) . innerHTML = conns . length > 0
? conns . map ( c => ` <span> ${ c } </span> ` ) . join ( '' )
: '<span>无连接记录</span>' ;
document . getElementById ( 'detail-status' ) . textContent = statusParts . join ( '; ' ) || '资料未记录' ;
// Mission
document . getElementById ( 'detail-mission' ) . textContent = primary . detailUrl
? ` 详见 Wiki: ${ primary . detailUrl } `
: '资料未记录' ;
const metrics = [ ] ;
components . forEach ( component => {
const dimensions = component . dimensions || { } ;
const properties = component . properties || { } ;
const values = [ ] ;
if ( dimensions . massT ) values . push ( ` 质量 ${ dimensions . massT } t ` ) ;
if ( properties . capacity ) values . push ( ` 铺位 ${ properties . capacity } ` ) ;
if ( properties . sleepBags ) values . push ( ` 睡袋 ${ properties . sleepBags } ` ) ;
if ( properties . zones ) values . push ( ` 温区 ${ properties . zones } ` ) ;
if ( properties . foldingArms ) values . push ( ` 折叠臂 ${ properties . foldingArms } ` ) ;
if ( properties . block ) values . push ( ` Block ${ properties . block } ` ) ;
if ( values . length ) metrics . push ( ` ${ component . name } : ${ values . join ( ', ' ) } ` ) ;
else if ( components . length > 1 && component . summary ) metrics . push ( ` ${ component . name } : ${ component . summary } ` ) ;
} ) ;
document . getElementById ( 'detail-metrics' ) . innerHTML =
metrics . map ( value => ` <span> ${ value } </span> ` ) . join ( '' ) || '<span>资料未记录</span>' ;
const dimensionLines = [ ] ;
components . forEach ( component => {
const dimensions = component . dimensions || { } ;
const values = [ ] ;
if ( dimensions . lengthM ) values . push ( ` 长 ${ dimensions . lengthM } m ` ) ;
if ( dimensions . widthM ) values . push ( ` 宽 ${ dimensions . widthM } m ` ) ;
if ( dimensions . heightM ) values . push ( ` 高 ${ dimensions . heightM } m ` ) ;
if ( dimensions . diameterM ) values . push ( ` 直径 ${ dimensions . diameterM } m ` ) ;
if ( values . length ) dimensionLines . push ( ` ${ component . name } : ${ values . join ( ', ' ) } ` ) ;
} ) ;
document . getElementById ( 'detail-dimensions' ) . innerHTML =
dimensionLines . map ( value => ` <span> ${ value } </span> ` ) . join ( '' ) || '<span>资料未记录</span>' ;
const connections = [ ] ;
components . forEach ( component => {
buildConnectionsForComponent ( component . key ) . forEach ( value => {
if ( ! connections . includes ( value ) ) connections . push ( value ) ;
} ) ;
} ) ;
document . getElementById ( 'detail-connections' ) . innerHTML =
connections . map ( value => ` <span> ${ value } </span> ` ) . join ( '' ) || '<span>无连接记录</span>' ;
document . getElementById ( 'detail-mission' ) . textContent =
primary . detailUrl ? ` 详见 Wiki: ${ primary . detailUrl } ` : '资料未记录' ;
// Show modal
const modal = document . getElementById ( 'detail-modal' ) ;
modal . setAttribute ( 'aria-hidden' , 'false' ) ;
document . body . classList . add ( 'modal-open' ) ;
@@ -459,8 +459,7 @@ function openModal(compKey) {
}
function closeModal ( ) {
const modal = document . getElementById ( 'detail-modal' ) ;
modal . setAttribute ( 'aria-hidden' , 'true' ) ;
document . getElementById ( 'detail-modal' ) . setAttribute ( 'aria-hidden' , 'true' ) ;
document . body . classList . remove ( 'modal-open' ) ;
}
@@ -468,72 +467,57 @@ async function load(mission, at) {
document . getElementById ( 'loading-msg' ) . style . display = 'block' ;
document . getElementById ( 'error-msg' ) . style . display = 'none' ;
document . getElementById ( 'map-shell' ) . style . display = 'none' ;
try {
currentData = await fetchSnapshot ( mission , at ) ;
renderTopology ( ) ;
const query = currentData . query ;
const missionLabel = query . requestedMission || '(最新)' ;
document . getElementById ( 'page-subtitle' ) . textContent =
` ${ missionLabel } · 快照时间: ${ new Date ( query . snapshotAt ) . toLocaleString ( 'zh-CN' ) } · ${ currentData . components . length } 组件 ` ;
document . getElementById ( 'snapshot-info' ) . textContent =
` 快照: ${ query . snapshotAt ? new Date ( query . snapshotAt ) . toLocaleString ( 'zh-CN' ) : '—' } · ${ query . anchorSource } ` ;
document . getElementById ( 'loading-msg' ) . style . display = 'none' ;
document . getElementById ( 'map-shell' ) . style . display = 'block' ;
const q = currentData . query ;
const missionLabel = q . requestedMission || '(最新)' ;
document . getElementById ( 'page-subtitle' ) . textContent =
` ${ missionLabel } · 快照时间: ${ new Date ( q . snapshotAt ) . toLocaleString ( 'zh-CN' ) } · ${ currentData . components . length } 组件 ` ;
document . getElementById ( 'snapshot-info' ) . textContent =
` 快照: ${ q . snapshotAt ? new Date ( q . snapshotAt ) . toLocaleString ( 'zh-CN' ) : '—' } · ${ q . anchorSource } ` ;
updateNodeVisibility ( ) ;
updateNodeLabels ( ) ;
} catch ( err ) {
} catch ( error ) {
document . getElementById ( 'loading-msg' ) . style . display = 'none' ;
document . getElementById ( 'error-msg' ) . style . display = 'block' ;
document . getElementById ( 'error-msg' ) . textContent = ` 加载失败: ${ err . message } ` ;
document . getElementById ( 'error-msg' ) . textContent = ` 加载失败: ${ error . message } ` ;
}
}
// Init
( async ( ) => {
// Setup mission dropdown
const missionSelect = document . getElementById ( 'mission-select' ) ;
const missions = await loadMissions ( ) ;
const sel = document . getElementById ( 'mission-select' ) ;
missions . forEach ( m => { const o = document . createElement ( 'option' ) ; o . value = m ; o . textContent = m ; sel . appendChild ( o ) ; } ) ;
missions . forEach ( mission => {
const option = document . createElement ( 'option' ) ;
option . value = mission ;
option . textContent = mission ;
missionSelect . appendChild ( option ) ;
} ) ;
// Parse URL params
const urlParams = new URLSearchParams ( window . location . search ) ;
const urlMission = urlParams . get ( 'mission' ) ;
const urlAt = urlParams . get ( 'at' ) ;
if ( urlMission ) sel . value = urlMission ;
if ( urlMission ) missionSelect . value = urlMission ;
if ( urlAt ) document . getElementById ( 'time-input' ) . value = urlAt ;
// Load initial data
await load ( urlMission || null , urlAt || null ) ;
// Wire up UI
document . getElementById ( 'go-btn' ) . addEventListener ( 'click' , ( ) => {
const m = document . getElementById ( 'mission-select' ) . value || null ;
const t = document . getElementById ( 'time-input' ) . value || null ;
// Update URL
const p = new URLSearchParams ( ) ;
if ( m ) p . set ( 'mission ' , m ) ;
if ( t ) p . set ( 'at' , t ) ;
const newUrl = ` ${ window . location . pathname } ${ p . toString ( ) ? '?' + p : '' } ` ;
window . history . pushState ( { } , '' , newUrl ) ;
load ( m , t ) ;
const mission = missionSelect . value || null ;
const at = document . getElementById ( 'time-input' ) . value || null ;
const params = new URLSearchParams ( ) ;
if ( mission ) params . set ( 'mission' , mission ) ;
if ( at ) params . set ( 'at ' , at ) ;
window . history . pushState ( { } , '' , ` ${ window . location . pathname } ${ params . size ? ` ? ${ params } ` : '' } ` ) ;
load ( mission , at ) ;
} ) ;
// Click handlers for topology nodes
document . querySelectorAll ( '.topology-node[data-comp]' ) . forEach ( node => {
node . addEventListener ( 'click' , ( ) => openModal ( node . dataset . comp ) ) ;
node . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault ( ) ; openModal ( node . dataset . comp ) ; } } ) ;
} ) ;
// Modal close
document . getElementById ( 'detail-modal' ) . addEventListener ( 'click' , ( e ) => {
if ( e . target === e . currentTarget ) closeModal ( ) ;
document . getElementById ( 'detail-modal' ) . addEventListener ( 'click' , event => {
if ( event . target === event . currentTarget ) closeModal ( ) ;
} ) ;
document . querySelector ( '.modal-close' ) . addEventListener ( 'click' , closeModal ) ;
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' && document . getElementById ( 'detail-modal' ) . getAttribute ( 'aria-hidden' ) === 'false' ) {
document . addEventListener ( 'keydown' , event => {
if ( event . key === 'Escape' && document . getElementById ( 'detail-modal' ) . getAttribute ( 'aria-hidden' ) === 'false' ) {
closeModal ( ) ;
}
} ) ;