feat(topology): render temporal graph from database

This commit is contained in:
2026-07-09 22:04:31 +08:00
parent 0dbcd3086e
commit 6ff18623e0
5 changed files with 430 additions and 451 deletions
+307 -323
View File
@@ -47,7 +47,6 @@
.topology-node { cursor: pointer; outline: none; } .topology-node { cursor: pointer; outline: none; }
.topology-node rect { vector-effect: non-scaling-stroke; transition: filter 150ms ease, stroke-width 150ms ease; } .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: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; } .node-card { fill: #10263a; stroke: var(--habitat); stroke-width: 3; }
.core-card { fill: #3b250d; stroke: var(--core); stroke-width: 5; } .core-card { fill: #3b250d; stroke: var(--core); stroke-width: 5; }
.power-card { fill: #2a2810; stroke: var(--power); stroke-width: 3; } .power-card { fill: #2a2810; stroke: var(--power); stroke-width: 3; }
@@ -97,9 +96,9 @@
<option value="">(最新)</option> <option value="">(最新)</option>
</select> </select>
<label for="time-input">时间:</label> <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> <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>
<div class="legend" aria-label="图例"> <div class="legend" aria-label="图例">
@@ -115,123 +114,13 @@
<div class="map-shell" id="map-shell" style="display:none;"> <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"> <svg class="topology-map" viewBox="0 0 1500 1900" role="img" aria-labelledby="topology-title" id="topology-svg">
<title id="topology-title">广寒基地拓扑</title> <title id="topology-title">广寒基地拓扑</title>
<desc id="topology-desc">核心舱四向安装四个舱段,南侧经居住舱一号连接电力模块十字结构</desc> <desc id="topology-desc">根据所选任务或时间展示广寒基地当时的工程拓扑</desc>
<!-- FUTURE_EXPANSION_ANCHOR:north -->
<!-- Structural: core to modules (N/E/S/W) --> <!-- FUTURE_EXPANSION_ANCHOR:east -->
<line class="connection structural" x1="750" y1="360" x2="750" y2="470" data-conn="core-structural" data-link="core_cabin.north-habitat_2.core"/> <!-- FUTURE_EXPANSION_ANCHOR:west -->
<line class="connection structural" x1="550" y1="570" x2="630" y2="570" data-conn="core-structural" data-link="core_cabin.west-greenhouse.core"/> <g id="connection-layer"></g>
<line class="connection structural" x1="870" y1="570" x2="950" y2="570" data-conn="core-structural" data-link="core_cabin.east-lab_module.core"/> <g id="decoration-layer"></g>
<line class="connection structural" x1="750" y1="670" x2="750" y2="760" data-conn="core-structural" data-link="core_cabin.south-habitat_1.core"/> <g id="node-layer"></g>
<!-- 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>
</svg> </svg>
</div> </div>
<p class="click-note">模块与车辆支持详情查看;走道、管线和散热面板仅用于表达拓扑关系。</p> <p class="click-note">模块与车辆支持详情查看;走道、管线和散热面板仅用于表达拓扑关系。</p>
@@ -256,202 +145,313 @@
<script> <script>
const API_BASE = ''; const API_BASE = '';
const SVG_NS = 'http://www.w3.org/2000/svg';
const TYPE_LABELS = { module: '舱段模块', node: '结构节点', vehicle: '着陆器/车辆', equipment: '设备模块' };
let currentData = null; let currentData = null;
// Group node members for composite SVG elements function svgElement(name, attributes = {}) {
const GROUP_MEMBERS = { const element = document.createElementNS(SVG_NS, name);
power_west_group: ['isru_module', 'mining_module', 'ore_fuel_storage'], Object.entries(attributes).forEach(([key, value]) => {
power_east_group: ['fuel_storage_mod', 'centrifuge_mod'], if (value !== null && value !== undefined) element.setAttribute(key, value);
}; });
return element;
const TYPE_LABELS = { module: '舱段模块', node: '结构节点', vehicle: '着陆器/车辆', equipment: '设备模块' }; }
async function fetchSnapshot(mission, at) { async function fetchSnapshot(mission, at) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (at) { params.set('at', at); } if (at) params.set('at', at);
else if (mission) { params.set('mission', mission); } else if (mission) params.set('mission', mission);
const url = `${API_BASE}/api/v1/bases/guanghan/snapshot?${params}`; const response = await fetch(`${API_BASE}/api/v1/bases/guanghan/snapshot?${params}`);
const res = await fetch(url); if (!response.ok) {
if (!res.ok) { const error = await response.json();
const err = await res.json(); throw new Error(error.message || `HTTP ${response.status}`);
throw new Error(err.message || `HTTP ${res.status}`);
} }
return res.json(); return response.json();
} }
async function loadMissions() { 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() { function splitEndpoint(endpoint) {
if (!currentData) return new Set(); const separator = endpoint.indexOf('.');
return new Set(currentData.components.map(c => c.key)); return separator === -1 ? endpoint : endpoint.slice(0, separator);
} }
function isGroupNodeVisible(groupKey) { function connectionClass(type) {
const members = GROUP_MEMBERS[groupKey]; if (type === 'structural_mount') return 'structural';
if (!members) return true; if (type === 'pressurized_passage') return 'pressurized';
const existing = getExistingComponentKeys(); return 'utility';
return members.some(m => existing.has(m));
} }
function updateNodeVisibility() { function edgePoint(source, target) {
if (!currentData) return; const sourceCenter = {
const existing = getExistingComponentKeys(); x: source.x + source.width / 2,
document.querySelectorAll('.topology-node[data-comp]').forEach(node => { y: source.y + source.height / 2,
const compKey = node.dataset.comp; };
let visible; const targetCenter = {
if (GROUP_MEMBERS[compKey]) { x: target.x + target.width / 2,
visible = isGroupNodeVisible(compKey); y: target.y + target.height / 2,
} else { };
visible = existing.has(compKey); const dx = targetCenter.x - sourceCenter.x;
} const dy = targetCenter.y - sourceCenter.y;
node.classList.toggle('hidden', !visible); if (dx === 0 && dy === 0) return sourceCenter;
}); const xScale = dx === 0 ? Infinity : (source.width / 2) / Math.abs(dx);
// Hide/show connections const yScale = dy === 0 ? Infinity : (source.height / 2) / Math.abs(dy);
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'] }; const scale = Math.min(xScale, yScale);
document.querySelectorAll('.connection[data-conn]').forEach(line => { return {
const conn = line.dataset.conn; x: sourceCenter.x + dx * scale,
const needed = connTypes[conn] || []; y: sourceCenter.y + dy * scale,
const visible = needed.length === 0 || needed.some(k => existing.has(k)); };
line.style.display = visible ? '' : 'none'; }
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() { function createRadiator(node) {
if (!currentData) return; const group = svgElement('g', { class: 'radiator', 'aria-label': '大型折叠散热面板' });
const compMap = {}; const width = Math.min(130, node.width * 0.55);
currentData.components.forEach(c => { compMap[c.key] = c; }); 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 => { function renderTopology() {
const compKey = node.dataset.comp; const payload = currentData.layout;
let comp; if (!payload || !payload.layout) throw new Error('拓扑布局资料未记录');
if (GROUP_MEMBERS[compKey]) {
const members = GROUP_MEMBERS[compKey].map(k => compMap[k]).filter(Boolean); const svg = document.getElementById('topology-svg');
if (members.length > 0) comp = members[0]; svg.setAttribute('viewBox', `0 0 ${payload.layout.canvasWidth} ${payload.layout.canvasHeight}`);
} else { const connectionLayer = document.getElementById('connection-layer');
comp = compMap[compKey]; const decorationLayer = document.getElementById('decoration-layer');
} const nodeLayer = document.getElementById('node-layer');
if (!comp) return; connectionLayer.replaceChildren();
// Update hint texts decorationLayer.replaceChildren();
const hints = node.querySelectorAll('.node-hint'); nodeLayer.replaceChildren();
if (hints.length > 0 && comp.summary) {
hints[0].textContent = comp.summary; const componentMap = new Map(currentData.components.map(component => [component.key, component]));
} const visibleNodes = payload.nodes
const states = comp.states || {}; .filter(node => node.memberKeys.some(key => componentMap.has(key)));
if (states.operation === 'operational' && states.operationDetails && states.operationDetails.powerPercent) { const nodeByKey = new Map(visibleNodes.map(node => [node.nodeKey, node]));
if (hints.length > 1) hints[1].textContent = `功率: ${states.operationDetails.powerPercent}%`; const nodeByComponent = new Map();
} visibleNodes.forEach(node => {
// Update meta node.memberKeys.forEach(key => {
const metas = node.querySelectorAll('.node-meta'); if (componentMap.has(key)) nodeByComponent.set(key, node);
if (metas.length > 0 && comp.type) { });
metas[0].textContent = TYPE_LABELS[comp.type] || comp.type; });
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) { function getNodeComponents(nodeKey) {
if (!currentData) return []; 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 = []; const result = [];
currentData.connections.forEach(conn => { currentData.connections.forEach(connection => {
const [fromComp, fromPort] = conn.fromPort.split('.'); const fromComponent = splitEndpoint(connection.fromPort);
const [toComp, toPort] = conn.toPort.split('.'); const toComponent = splitEndpoint(connection.toPort);
if (fromComp === compKey || toComp === compKey) { if (fromComponent !== componentKey && toComponent !== componentKey) return;
const typeLabel = { structural_mount: '结构直连', pressurized_passage: '加压走道', fuel: '燃料管线', power: '供电管线', docking: '对接' }[conn.type] || conn.type; const labels = {
const other = fromComp === compKey ? toComp : fromComp; structural_mount: '结构直连',
const otherComp = currentData.components.find(c => c.key === other); pressurized_passage: '加压走道',
const otherName = otherComp ? otherComp.name : other; fuel: '燃料管线',
const dir = fromComp === compKey ? '→' : ''; power: '供电管线',
result.push(`${dir} ${otherName}${typeLabel}`); 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; return result;
} }
function openModal(compKey) { function openModal(nodeKey) {
if (!currentData) return; const components = getNodeComponents(nodeKey);
const compMap = {}; if (components.length === 0) return;
currentData.components.forEach(c => { compMap[c.key] = c; }); 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 = []; const ports = components.flatMap(component =>
if (GROUP_MEMBERS[compKey]) { (component.ports || []).map(port => `${component.name}${port.displayName}${port.direction || '—'}`)
comps = GROUP_MEMBERS[compKey].map(k => compMap[k]).filter(Boolean); );
} else if (compMap[compKey]) { document.getElementById('detail-location').textContent = ports.join(' · ') || '资料未记录';
comps = [compMap[compKey]];
}
if (comps.length === 0) return;
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 = []; const statusParts = [];
if (states.lifecycle) statusParts.push(states.lifecycle === 'installed' ? '已安装' : states.lifecycle); components.forEach(component => {
if (states.operation) { const states = component.states || {};
const opLabel = { operational: '运行中', standby: '待机', offline: '离线' }[states.operation] || states.operation; const values = [];
statusParts.push(opLabel); if (states.lifecycle) values.push(states.lifecycle === 'installed' ? '已安装' : states.lifecycle);
if (states.operationDetails && states.operationDetails.powerPercent) { if (states.operation) {
statusParts.push(`功率 ${states.operationDetails.powerPercent}%`); values.push({ operational: '运行中', standby: '待机', offline: '离线' }[states.operation] || states.operation);
if (states.operationDetails?.powerPercent) values.push(`功率 ${states.operationDetails.powerPercent}%`);
} }
} if (values.length) statusParts.push(`${component.name}${values.join(' · ')}`);
document.getElementById('detail-status').textContent = statusParts.join(' · ') || '资料未记录'; });
document.getElementById('detail-status').textContent = statusParts.join('') || '资料未记录';
// Metrics
const dims = primary.dimensions || {};
const props = primary.properties || {};
const metrics = []; const metrics = [];
if (dims.massT) metrics.push(`质量: ${dims.massT} t`); components.forEach(component => {
if (props.capacity) metrics.push(`铺位: ${props.capacity}`); const dimensions = component.dimensions || {};
if (props.sleepBags) metrics.push(`睡袋: ${props.sleepBags}`); const properties = component.properties || {};
if (props.zones) metrics.push(`温区: ${props.zones}`); const values = [];
if (props.foldingArms) metrics.push(`折叠臂: ${props.foldingArms}`); if (dimensions.massT) values.push(`质量 ${dimensions.massT} t`);
if (props.block) metrics.push(`构型: Block ${props.block}`); if (properties.capacity) values.push(`铺位 ${properties.capacity}`);
if (props.dryMassT) metrics.push(`干重: ${props.dryMassT} t`); if (properties.sleepBags) values.push(`睡袋 ${properties.sleepBags}`);
if (props.batteryKwh) metrics.push(`蓄电池: ${props.batteryKwh} kWh`); if (properties.zones) values.push(`温区 ${properties.zones}`);
if (props.dockingNodes) metrics.push(`安装节点: ${props.dockingNodes}`); if (properties.foldingArms) values.push(`折叠臂 ${properties.foldingArms}`);
if (metrics.length === 0 && comps.length > 1) { if (properties.block) values.push(`Block ${properties.block}`);
comps.forEach(c => { if (c.summary) metrics.push(`${c.name}: ${c.summary}`); }); 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(m => `<span>${m}</span>`).join('') || '<span>资料未记录</span>'; });
document.getElementById('detail-metrics').innerHTML =
metrics.map(value => `<span>${value}</span>`).join('') || '<span>资料未记录</span>';
// Dimensions const dimensionLines = [];
const dimLines = []; components.forEach(component => {
if (dims.lengthM) dimLines.push(`长: ${dims.lengthM} m`); const dimensions = component.dimensions || {};
if (dims.widthM) dimLines.push(`宽: ${dims.widthM} m`); const values = [];
if (dims.heightM) dimLines.push(`高: ${dims.heightM} m`); if (dimensions.lengthM) values.push(` ${dimensions.lengthM} m`);
if (dims.diameterM) dimLines.push(`直径: ${dims.diameterM} m`); if (dimensions.widthM) values.push(` ${dimensions.widthM} m`);
if (props.transportHeightM) dimLines.push(`运输: ${props.transportHeightM}×${props.transportLengthM}×${props.transportWidthM} m`); if (dimensions.heightM) values.push(` ${dimensions.heightM} m`);
document.getElementById('detail-dimensions').innerHTML = dimLines.map(l => `<span>${l}</span>`).join('') || '<span>资料未记录</span>'; 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 connections = [];
const conns = buildConnectionsForComponent(compKey); components.forEach(component => {
if (comps.length > 1) { buildConnectionsForComponent(component.key).forEach(value => {
comps.slice(1).forEach(c => { if (!connections.includes(value)) connections.push(value);
buildConnectionsForComponent(c.key).forEach(cc => { if (!conns.includes(cc)) conns.push(cc); });
}); });
} });
document.getElementById('detail-connections').innerHTML = conns.length > 0 document.getElementById('detail-connections').innerHTML =
? conns.map(c => `<span>${c}</span>`).join('') connections.map(value => `<span>${value}</span>`).join('') || '<span>无连接记录</span>';
: '<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'); const modal = document.getElementById('detail-modal');
modal.setAttribute('aria-hidden', 'false'); modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open'); document.body.classList.add('modal-open');
@@ -459,8 +459,7 @@ function openModal(compKey) {
} }
function closeModal() { function closeModal() {
const modal = document.getElementById('detail-modal'); document.getElementById('detail-modal').setAttribute('aria-hidden', 'true');
modal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
} }
@@ -468,72 +467,57 @@ async function load(mission, at) {
document.getElementById('loading-msg').style.display = 'block'; document.getElementById('loading-msg').style.display = 'block';
document.getElementById('error-msg').style.display = 'none'; document.getElementById('error-msg').style.display = 'none';
document.getElementById('map-shell').style.display = 'none'; document.getElementById('map-shell').style.display = 'none';
try { try {
currentData = await fetchSnapshot(mission, at); 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('loading-msg').style.display = 'none';
document.getElementById('map-shell').style.display = 'block'; document.getElementById('map-shell').style.display = 'block';
} catch (error) {
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) {
document.getElementById('loading-msg').style.display = 'none'; document.getElementById('loading-msg').style.display = 'none';
document.getElementById('error-msg').style.display = 'block'; document.getElementById('error-msg').style.display = 'block';
document.getElementById('error-msg').textContent = `加载失败: ${err.message}`; document.getElementById('error-msg').textContent = `加载失败: ${error.message}`;
} }
} }
// Init
(async () => { (async () => {
// Setup mission dropdown const missionSelect = document.getElementById('mission-select');
const missions = await loadMissions(); const missions = await loadMissions();
const sel = document.getElementById('mission-select'); missions.forEach(mission => {
missions.forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = m; sel.appendChild(o); }); 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 urlParams = new URLSearchParams(window.location.search);
const urlMission = urlParams.get('mission'); const urlMission = urlParams.get('mission');
const urlAt = urlParams.get('at'); const urlAt = urlParams.get('at');
if (urlMission) sel.value = urlMission; if (urlMission) missionSelect.value = urlMission;
if (urlAt) document.getElementById('time-input').value = urlAt; if (urlAt) document.getElementById('time-input').value = urlAt;
// Load initial data
await load(urlMission || null, urlAt || null); await load(urlMission || null, urlAt || null);
// Wire up UI
document.getElementById('go-btn').addEventListener('click', () => { document.getElementById('go-btn').addEventListener('click', () => {
const m = document.getElementById('mission-select').value || null; const mission = missionSelect.value || null;
const t = document.getElementById('time-input').value || null; const at = document.getElementById('time-input').value || null;
// Update URL const params = new URLSearchParams();
const p = new URLSearchParams(); if (mission) params.set('mission', mission);
if (m) p.set('mission', m); if (at) params.set('at', at);
if (t) p.set('at', t); window.history.pushState({}, '', `${window.location.pathname}${params.size ? `?${params}` : ''}`);
const newUrl = `${window.location.pathname}${p.toString() ? '?' + p : ''}`; load(mission, at);
window.history.pushState({}, '', newUrl);
load(m, t);
}); });
// Click handlers for topology nodes document.getElementById('detail-modal').addEventListener('click', event => {
document.querySelectorAll('.topology-node[data-comp]').forEach(node => { if (event.target === event.currentTarget) closeModal();
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.querySelector('.modal-close').addEventListener('click', closeModal); document.querySelector('.modal-close').addEventListener('click', closeModal);
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', event => {
if (e.key === 'Escape' && document.getElementById('detail-modal').getAttribute('aria-hidden') === 'false') { if (event.key === 'Escape' && document.getElementById('detail-modal').getAttribute('aria-hidden') === 'false') {
closeModal(); closeModal();
} }
}); });
+20 -90
View File
@@ -1,114 +1,44 @@
from __future__ import annotations from __future__ import annotations
import re
import unittest import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
HTML_PATH = ROOT / "data" / "wiki" / "guanghan_topology_demo.html" HTML_PATH = ROOT / "data" / "wiki" / "guanghan_topology_demo.html"
TIMELINE_PATH = ROOT / "user_input" / "广寒计划" / "Timeline.md"
CLICKABLE_MODULES = {
"core",
"habitat-1",
"habitat-2",
"greenhouse",
"laboratory",
"power-node",
"power-north",
"power-east",
"power-south",
"power-west",
"wugang-2",
"binglun-1",
"binglun-2",
"binglun-3",
}
class GuanghanTopologySourceTests(unittest.TestCase): class GuanghanTopologySourceTests(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
cls.html = HTML_PATH.read_text(encoding="utf-8") cls.html = HTML_PATH.read_text(encoding="utf-8")
timeline = TIMELINE_PATH.read_text(encoding="utf-8")
cls.ghc05 = timeline.split("GHC-05任务", 1)[1].split("JC-02任务", 1)[0]
def test_clickable_module_inventory_is_exact(self) -> None: def test_topology_is_rendered_from_snapshot_layout(self) -> None:
found = set(re.findall(r'data-module="([^"]+)"', self.html)) self.assertNotIn('<g class="topology-node"', self.html)
self.assertEqual(found, CLICKABLE_MODULES) self.assertNotIn("GROUP_MEMBERS", self.html)
self.assertNotIn("data-conn=", self.html)
self.assertIn("currentData.layout", self.html)
self.assertIn("currentData.connections", self.html)
self.assertIn("function renderTopology", self.html)
def test_connection_inventory_matches_approved_topology(self) -> None: def test_radiators_are_dynamic_layout_decorations(self) -> None:
self.assertEqual(self.html.count('data-connection="pressurized"'), 2) self.assertNotIn('<g class="radiator">', self.html)
self.assertIn('data-link="wugang-2--greenhouse"', self.html) self.assertIn("decorations.radiator", self.html)
self.assertIn('data-link="habitat-1--power-north"', self.html) self.assertIn("function createRadiator", self.html)
self.assertNotIn("perimeter-corridor", self.html)
def test_walkways_and_radiators_are_not_clickable(self) -> None: def test_future_expansion_anchors_remain_source_comments(self) -> None:
self.assertNotRegex(
self.html,
r'<(?:line|path|rect)[^>]+data-connection="pressurized"[^>]+data-module=',
)
self.assertNotRegex(
self.html,
r'<(?:g|rect)[^>]+class="[^"]*radiator[^"]*"[^>]+data-module=',
)
def test_each_radiator_has_a_visible_text_label(self) -> None:
radiator_groups = re.findall(
r'<g class="radiator"[^>]*>(.*?)</g>',
self.html,
flags=re.S,
)
self.assertEqual(len(radiator_groups), 4)
for group in radiator_groups:
self.assertIn(">散热器</text>", group)
rect = re.search(r'<rect x="(\d+)" y="(\d+)" width="(\d+)"', group)
label = re.search(r'<text[^>]+x="(\d+)" y="(\d+)"[^>]*>散热器</text>', group)
self.assertIsNotNone(rect)
self.assertIsNotNone(label)
rect_x, rect_y, rect_width = map(int, rect.groups())
label_x, label_y = map(int, label.groups())
self.assertGreaterEqual(label_x, rect_x)
self.assertLessEqual(label_x, rect_x + rect_width)
self.assertLess(label_y, rect_y)
def test_future_extensions_exist_only_as_source_comments(self) -> None:
comments = "\n".join(re.findall(r"<!--(.*?)-->", self.html, flags=re.S))
visible_source = re.sub(r"<!--.*?-->", "", self.html, flags=re.S)
for direction in ("north", "east", "west"): for direction in ("north", "east", "west"):
self.assertIn(f"FUTURE_EXPANSION_ANCHOR:{direction}", comments) self.assertIn(
self.assertNotIn("未来北部十字结构", visible_source) f"FUTURE_EXPANSION_ANCHOR:{direction}",
self.assertNotIn("未来东部十字结构", visible_source)
self.assertNotIn("未来西部十字结构", visible_source)
def test_every_clickable_module_has_dimensions_field(self) -> None:
for module_id in CLICKABLE_MODULES:
match = re.search(
rf'"{re.escape(module_id)}"\s*:\s*\{{(.*?)\n \}}(?:,|\n)',
self.html, self.html,
flags=re.S,
) )
self.assertIsNotNone(match, msg=f"{module_id} detail object is missing") self.assertNotIn("未来北部十字结构", self.html)
self.assertIn("dimensions:", match.group(1), msg=f"{module_id} is missing dimensions") self.assertNotIn("未来东部十字结构", self.html)
self.assertNotIn("未来西部十字结构", self.html)
def test_timeline_contains_only_the_two_real_corridors(self) -> None: def test_page_uses_same_origin_api(self) -> None:
for incorrect in ( self.assertIn("const API_BASE = ''", self.html)
"4段加压走道", self.assertNotIn("http://localhost:5000", self.html)
"居住舱#1↔居住舱#2",
"居住舱#2↔实验舱",
"实验舱↔温室",
"温室↔居住舱#1",
"环形加压通路",
"8个对接面",
"4段加压走道48小时",
):
self.assertNotIn(incorrect, self.ghc05)
self.assertIn("居住舱#1远端对接口", self.ghc05)
self.assertIn("电力模块北部", self.ghc05)
self.assertIn("温室远端对接口", self.ghc05)
self.assertIn("吴刚#2", self.ghc05)
self.assertIn("从温室模块远端接口断开", self.ghc05)
if __name__ == "__main__": if __name__ == "__main__":
+83 -37
View File
@@ -1,27 +1,26 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import functools
import threading import threading
import unittest import unittest
from contextlib import contextmanager from contextlib import contextmanager
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
from werkzeug.serving import make_server
from topology_api.app import create_app
ROOT = Path(__file__).resolve().parents[1]
@contextmanager @contextmanager
def local_server(): def topology_server():
handler = functools.partial(SimpleHTTPRequestHandler, directory=str(ROOT)) app = create_app()
server = ThreadingHTTPServer(("127.0.0.1", 0), handler) app.config.update(TESTING=True)
server = make_server("127.0.0.1", 0, app, threaded=True)
thread = threading.Thread(target=server.serve_forever, daemon=True) thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start() thread.start()
try: try:
yield f"http://127.0.0.1:{server.server_port}" port = server.socket.getsockname()[1]
yield f"http://127.0.0.1:{port}"
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
@@ -29,44 +28,91 @@ def local_server():
class GuanghanTopologyE2ETests(unittest.TestCase): class GuanghanTopologyE2ETests(unittest.TestCase):
def test_rendered_topology_and_modal_interactions(self) -> None: def test_historical_graph_and_ghc05_rendering(self) -> None:
async def scenario(base_url: str) -> None: async def scenario(base_url: str) -> None:
async with async_playwright() as pw: async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True) browser = await pw.chromium.launch(headless=True)
page = await browser.new_page(viewport={"width": 1440, "height": 1100}) page = await browser.new_page(
errors: list[str] = [] viewport={"width": 1440, "height": 1000}
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None) )
await page.goto( console_errors: list[str] = []
f"{base_url}/data/wiki/guanghan_topology_demo.html", page.on(
wait_until="networkidle", "console",
lambda msg: (
console_errors.append(msg.text)
if msg.type == "error"
else None
),
) )
self.assertEqual(await page.locator("[data-module]").count(), 14) async def open_mission(code: str) -> None:
self.assertEqual(await page.locator('[data-connection="pressurized"]').count(), 2) await page.goto(
strokes = await page.locator('[data-connection="pressurized"]').evaluate_all( f"{base_url}/guanghan_topology_demo.html"
f"?mission={code}",
wait_until="networkidle",
)
await page.wait_for_function(
"""code => (
document.querySelector('#page-subtitle')
?.textContent.startsWith(code + ' ·')
)""",
arg=code,
)
await open_mission("BL-01")
self.assertEqual(
await page.locator(".topology-node").count(), 1
)
self.assertEqual(
await page.locator(".connection").count(), 0
)
self.assertEqual(await page.locator(".radiator").count(), 0)
await open_mission("GHC-01")
self.assertEqual(
await page.locator(".topology-node").count(), 2
)
self.assertEqual(
await page.locator(".connection").count(), 0
)
await open_mission("GHC-05")
self.assertEqual(
await page.locator(".topology-node").count(), 14
)
self.assertEqual(
await page.locator(".connection.pressurized").count(), 2
)
self.assertEqual(await page.locator(".radiator").count(), 4)
strokes = await page.locator(
".connection.pressurized"
).evaluate_all(
"(els) => els.map((el) => getComputedStyle(el).stroke)" "(els) => els.map((el) => getComputedStyle(el).stroke)"
) )
self.assertEqual(strokes, ["rgb(81, 232, 156)", "rgb(81, 232, 156)"]) self.assertEqual(
strokes,
["rgb(81, 232, 156)", "rgb(81, 232, 156)"],
)
for forbidden in ("未来北部十字结构", "未来东部十字结构", "未来西部十字结构"): overflows = await page.locator(
self.assertEqual(await page.get_by_text(forbidden, exact=False).count(), 0) ".topology-node"
).evaluate_all(
await page.locator('[data-module="core"]').click() """nodes => nodes.flatMap(node => {
self.assertEqual(await page.locator("#detail-modal").count(), 1) const rect = node.querySelector('rect').getBBox();
self.assertEqual(await page.locator("#detail-modal").get_attribute("aria-hidden"), "false") return [...node.querySelectorAll('text')]
self.assertEqual(await page.locator("#detail-title").inner_text(), "广寒核心舱") .map(text => ({text: text.textContent,
await page.keyboard.press("Escape") box: text.getBBox()}))
self.assertEqual(await page.locator("#detail-modal").get_attribute("aria-hidden"), "true") .filter(item =>
item.box.x < rect.x ||
await page.locator('[data-module="habitat-1"]').click() item.box.x + item.box.width > rect.x + rect.width
self.assertIn("运输:高2.7 m", await page.locator("#detail-dimensions").inner_text()) );
await page.locator("#detail-modal").click(position={"x": 5, "y": 5}) })"""
self.assertEqual(await page.locator("#detail-modal").get_attribute("aria-hidden"), "true") )
self.assertEqual(overflows, [])
self.assertEqual(errors, []) self.assertEqual(console_errors, [])
await browser.close() await browser.close()
with local_server() as base_url: with topology_server() as base_url:
asyncio.run(scenario(base_url)) asyncio.run(scenario(base_url))
+17
View File
@@ -124,6 +124,23 @@ class TopologyApiQueryTests(unittest.TestCase):
}, },
) )
def test_habitat_walkway_terminates_at_power_north_module(self) -> None:
response = self.client.get(
"/api/v1/bases/guanghan/snapshot?mission=GHC-05"
)
pressurized = {
frozenset((connection["fromPort"], connection["toPort"]))
for connection in response.get_json()["connections"]
if connection["type"] == "pressurized_passage"
}
self.assertIn(
frozenset((
"habitat_1.distal",
"power_storage_rack.passage",
)),
pressurized,
)
def test_layout_contains_members_labels_and_radiators(self) -> None: def test_layout_contains_members_labels_and_radiators(self) -> None:
response = self.client.get( response = self.client.get(
"/api/v1/bases/guanghan/snapshot?mission=GHC-05" "/api/v1/bases/guanghan/snapshot?mission=GHC-05"
+3 -1
View File
@@ -428,6 +428,8 @@ def seed():
"mining_module", "ore_fuel_storage", "fuel_storage_mod", "mining_module", "ore_fuel_storage", "fuel_storage_mod",
"centrifuge_mod"]: "centrifuge_mod"]:
add_port(key, "mount", "安装接口") add_port(key, "mount", "安装接口")
add_port("power_storage_rack", "passage", "加压走道接口", "north",
interface_type="pressurized")
# Habitat modules: core-side + distal # Habitat modules: core-side + distal
add_port("habitat_1", "core", "核心端接口", "north") add_port("habitat_1", "core", "核心端接口", "north")
@@ -550,7 +552,7 @@ def seed():
# GHC-05: Pressurized passages # GHC-05: Pressurized passages
add_connection("greenhouse", "distal", "wugang_2", "dock", add_connection("greenhouse", "distal", "wugang_2", "dock",
"pressurized_passage", "GHC-05_7") "pressurized_passage", "GHC-05_7")
add_connection("habitat_1", "distal", "power_module_hub", "north", add_connection("habitat_1", "distal", "power_storage_rack", "passage",
"pressurized_passage", "GHC-05_4") "pressurized_passage", "GHC-05_4")
print("Connections created") print("Connections created")