feat(topology): render temporal graph from database
This commit is contained in:
@@ -47,7 +47,6 @@
|
||||
.topology-node { cursor: pointer; outline: none; }
|
||||
.topology-node rect { vector-effect: non-scaling-stroke; transition: filter 150ms ease, stroke-width 150ms 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);
|
||||
}
|
||||
node.classList.toggle('hidden', !visible);
|
||||
});
|
||||
// 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';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function appendNodeText(group, node, className, text, y) {
|
||||
if (!text) return;
|
||||
const element = svgElement('text', {
|
||||
class: className,
|
||||
x: node.x + node.width / 2,
|
||||
y,
|
||||
});
|
||||
group.appendChild(element);
|
||||
fitSvgText(element, text, node.width - 24);
|
||||
}
|
||||
|
||||
function updateNodeLabels() {
|
||||
if (!currentData) return;
|
||||
const compMap = {};
|
||||
currentData.components.forEach(c => { compMap[c.key] = c; });
|
||||
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;
|
||||
}
|
||||
|
||||
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 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 buildConnectionsForComponent(compKey) {
|
||||
if (!currentData) return [];
|
||||
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 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);
|
||||
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}%`);
|
||||
components.forEach(component => {
|
||||
const states = component.states || {};
|
||||
const values = [];
|
||||
if (states.lifecycle) values.push(states.lifecycle === 'installed' ? '已安装' : states.lifecycle);
|
||||
if (states.operation) {
|
||||
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(' · ') || '资料未记录';
|
||||
if (values.length) statusParts.push(`${component.name}:${values.join(' · ')}`);
|
||||
});
|
||||
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>';
|
||||
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>';
|
||||
|
||||
// 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>';
|
||||
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>';
|
||||
|
||||
// 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); });
|
||||
const connections = [];
|
||||
components.forEach(component => {
|
||||
buildConnectionsForComponent(component.key).forEach(value => {
|
||||
if (!connections.includes(value)) connections.push(value);
|
||||
});
|
||||
}
|
||||
document.getElementById('detail-connections').innerHTML = conns.length > 0
|
||||
? conns.map(c => `<span>${c}</span>`).join('')
|
||||
: '<span>无连接记录</span>';
|
||||
});
|
||||
document.getElementById('detail-connections').innerHTML =
|
||||
connections.map(value => `<span>${value}</span>`).join('') || '<span>无连接记录</span>';
|
||||
document.getElementById('detail-mission').textContent =
|
||||
primary.detailUrl ? `详见 Wiki:${primary.detailUrl}` : '资料未记录';
|
||||
|
||||
// Mission
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user