528 lines
24 KiB
HTML
528 lines
24 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>广寒基地拓扑结构</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #070d18; --panel: #0d1728; --panel-2: #101c30;
|
||
--text: #eaf2ff; --muted: #91a3bd; --border: #263750;
|
||
--structural: #8fa1b8; --pressurized: #51e89c; --utility: #b178e6;
|
||
--core: #ffad42; --habitat: #52baff; --power: #d5b642;
|
||
--vehicle: #c28cdf; --radiator: #f0d869;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; min-height: 100vh; padding: 30px;
|
||
background: radial-gradient(circle at 50% 0%, rgba(51,88,140,0.18), transparent 36rem), var(--bg);
|
||
color: var(--text);
|
||
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
|
||
}
|
||
.page { width: min(100%, 1500px); margin: 0 auto; }
|
||
.page-header { text-align: center; margin-bottom: 22px; }
|
||
h1 { margin: 0 0 7px; font-size: clamp(1.7rem, 3vw, 2.45rem); font-weight: 650; letter-spacing: 0.08em; }
|
||
.subtitle { margin: 0; color: var(--muted); }
|
||
.toolbar { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 12px; margin: 16px 0; }
|
||
.toolbar label { color: var(--muted); font-size: 0.88rem; }
|
||
.toolbar select, .toolbar input { padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font-size: 0.88rem; }
|
||
.toolbar button { padding: 6px 16px; border: 1px solid var(--pressurized); border-radius: 6px; background: #0a2e16; color: var(--pressurized); cursor: pointer; font-size: 0.88rem; font-weight: 600; }
|
||
.toolbar button:hover { background: #0f3d1f; }
|
||
.loading-msg { text-align: center; color: var(--muted); padding: 40px; }
|
||
.error-msg { text-align: center; color: #f66; padding: 40px; }
|
||
.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: 13px 24px; margin: 20px 0; padding: 13px 18px; border: 1px solid var(--border); border-radius: 12px; background: rgba(13,23,40,0.84); }
|
||
.legend-item { display: inline-flex; align-items: center; gap: 9px; color: #c8d5e7; font-size: 0.86rem; }
|
||
.legend-line { display: inline-block; width: 46px; border-top: 4px solid; }
|
||
.legend-line.structural { border-color: var(--structural); }
|
||
.legend-line.pressurized { border-color: var(--pressurized); }
|
||
.legend-line.utility { border-color: var(--utility); border-top-style: dashed; }
|
||
.legend-chip { width: 19px; height: 13px; border: 2px solid var(--radiator); background: #665511; border-radius: 3px; }
|
||
.map-shell { overflow: auto; border: 1px solid var(--border); border-radius: 18px; background: #08101e; box-shadow: 0 24px 70px rgba(0,0,0,0.34); }
|
||
.topology-map { display: block; width: 100%; min-width: 1080px; height: auto; background: linear-gradient(rgba(18,36,60,0.21) 1px, transparent 1px), linear-gradient(90deg, rgba(18,36,60,0.21) 1px, transparent 1px), #08101e; background-size: 48px 48px; }
|
||
.connection { fill: none; vector-effect: non-scaling-stroke; stroke-linecap: square; }
|
||
.connection.structural { stroke: var(--structural); stroke-width: 6; }
|
||
.connection.pressurized { stroke: var(--pressurized); stroke-width: 9; }
|
||
.connection.utility { stroke: var(--utility); stroke-width: 4; stroke-dasharray: 13 10; }
|
||
.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; }
|
||
.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; }
|
||
.power-node-card { fill: #393412; stroke: var(--power); stroke-width: 5; }
|
||
.vehicle-card { fill: #291c34; stroke: var(--vehicle); stroke-width: 3; }
|
||
.node-title { fill: var(--text); font-size: 25px; font-weight: 700; text-anchor: middle; }
|
||
.node-meta { fill: #a6b5c9; font-size: 17px; text-anchor: middle; }
|
||
.node-hint { fill: #71849e; font-size: 14px; text-anchor: middle; }
|
||
.radiator { pointer-events: none; }
|
||
.radiator rect { fill: #6b5814; stroke: var(--radiator); stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||
.radiator line { stroke: #f8e798; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
|
||
.radiator-label { fill: #f4df7b; font-size: 15px; font-weight: 650; text-anchor: middle; }
|
||
.click-note { margin: 13px 0 0; color: var(--muted); font-size: 0.84rem; text-align: center; }
|
||
body.modal-open { overflow: hidden; }
|
||
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(2,7,14,0.82); backdrop-filter: blur(8px); }
|
||
.modal-backdrop[aria-hidden="false"] { display: flex; }
|
||
.detail-dialog { position: relative; width: min(760px, 100%); max-height: min(820px, calc(100vh - 48px)); overflow: auto; padding: 30px; border: 1px solid #405572; border-radius: 18px; outline: none; background: linear-gradient(145deg, #13233a, #0b1424 72%); box-shadow: 0 30px 100px rgba(0,0,0,0.64); }
|
||
.detail-dialog:focus-visible { box-shadow: 0 0 0 3px var(--pressurized), 0 30px 100px rgba(0,0,0,0.64); }
|
||
.modal-close { position: absolute; top: 14px; right: 14px; width: 40px; height: 40px; border: 1px solid #4e627e; border-radius: 50%; background: #182a43; color: var(--text); font: 500 1.65rem/1 system-ui, sans-serif; cursor: pointer; }
|
||
.modal-close:hover, .modal-close:focus-visible { border-color: var(--pressurized); outline: none; background: #203754; }
|
||
.detail-kicker { margin: 0 46px 6px 0; color: var(--pressurized); font-size: 0.77rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||
#detail-title { margin: 0 50px 10px 0; font-size: 1.85rem; }
|
||
#detail-role { margin: 0 0 22px; color: #c4d2e4; line-height: 1.7; }
|
||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0; }
|
||
.detail-grid > div { min-width: 0; padding: 14px; border: 1px solid #293c57; border-radius: 10px; background: rgba(8,18,33,0.7); }
|
||
.detail-grid dt { margin-bottom: 7px; color: #7f94b1; font-size: 0.75rem; }
|
||
.detail-grid dd { margin: 0; color: #e1ebf8; line-height: 1.55; overflow-wrap: anywhere; }
|
||
.detail-grid dd span { display: block; }
|
||
.detail-grid dd span + span { margin-top: 4px; }
|
||
@media (max-width: 760px) {
|
||
body { padding: 14px; } .topology-map { min-width: 980px; } .legend { justify-content: flex-start; }
|
||
.modal-backdrop { padding: 12px; } .detail-dialog { padding: 25px 18px 20px; max-height: calc(100vh - 24px); }
|
||
.detail-grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="page">
|
||
<header class="page-header">
|
||
<h1>广寒基地拓扑结构</h1>
|
||
<p class="subtitle" id="page-subtitle">加载中...</p>
|
||
</header>
|
||
|
||
<div class="toolbar">
|
||
<label for="mission-select">任务:</label>
|
||
<select id="mission-select">
|
||
<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">
|
||
<button id="go-btn">查询</button>
|
||
<span class="snapshot-info" id="snapshot-info"></span>
|
||
</div>
|
||
|
||
<div class="legend" aria-label="图例">
|
||
<span class="legend-item"><i class="legend-line structural"></i>结构直连</span>
|
||
<span class="legend-item"><i class="legend-line pressurized"></i>加压走道</span>
|
||
<span class="legend-item"><i class="legend-line utility"></i>燃料 / 供电</span>
|
||
<span class="legend-item"><i class="legend-chip"></i>大型折叠散热面板</span>
|
||
</div>
|
||
|
||
<div id="loading-msg" class="loading-msg">正在从 API 加载拓扑数据...</div>
|
||
<div id="error-msg" class="error-msg" style="display:none;"></div>
|
||
|
||
<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>
|
||
<!-- 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>
|
||
</main>
|
||
|
||
<div class="modal-backdrop" id="detail-modal" aria-hidden="true">
|
||
<section class="detail-dialog" role="dialog" aria-modal="true" aria-labelledby="detail-title" tabindex="-1">
|
||
<button class="modal-close" type="button" aria-label="关闭详情">×</button>
|
||
<p class="detail-kicker" id="detail-type"></p>
|
||
<h2 id="detail-title"></h2>
|
||
<p id="detail-role"></p>
|
||
<dl class="detail-grid">
|
||
<div><dt>安装位置</dt><dd id="detail-location"></dd></div>
|
||
<div><dt>运行状态</dt><dd id="detail-status"></dd></div>
|
||
<div><dt>关键参数</dt><dd id="detail-metrics"></dd></div>
|
||
<div><dt>尺寸</dt><dd id="detail-dimensions"></dd></div>
|
||
<div><dt>连接关系</dt><dd id="detail-connections"></dd></div>
|
||
<div><dt>任务节点</dt><dd id="detail-mission"></dd></div>
|
||
</dl>
|
||
</section>
|
||
</div>
|
||
|
||
<script>
|
||
const API_BASE = '';
|
||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||
const TYPE_LABELS = { module: '舱段模块', node: '结构节点', vehicle: '着陆器/车辆', equipment: '设备模块' };
|
||
let currentData = null;
|
||
|
||
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 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 response.json();
|
||
}
|
||
|
||
async function loadMissions() {
|
||
return ['BL-01', 'GHC-01', 'JC-01', 'BL-02', 'GHC-02', 'GHC-03', 'BL-03', 'GHC-04', 'GHC-05'];
|
||
}
|
||
|
||
function splitEndpoint(endpoint) {
|
||
const separator = endpoint.indexOf('.');
|
||
return separator === -1 ? endpoint : endpoint.slice(0, separator);
|
||
}
|
||
|
||
function connectionClass(type) {
|
||
if (type === 'structural_mount') return 'structural';
|
||
if (type === 'pressurized_passage') return 'pressurized';
|
||
return 'utility';
|
||
}
|
||
|
||
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 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 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(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(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(';');
|
||
|
||
const ports = components.flatMap(component =>
|
||
(component.ports || []).map(port => `${component.name}:${port.displayName}(${port.direction || '—'})`)
|
||
);
|
||
document.getElementById('detail-location').textContent = ports.join(' · ') || '资料未记录';
|
||
|
||
const statusParts = [];
|
||
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}%`);
|
||
}
|
||
if (values.length) statusParts.push(`${component.name}:${values.join(' · ')}`);
|
||
});
|
||
document.getElementById('detail-status').textContent = statusParts.join(';') || '资料未记录';
|
||
|
||
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}` : '资料未记录';
|
||
|
||
const modal = document.getElementById('detail-modal');
|
||
modal.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('modal-open');
|
||
modal.querySelector('.detail-dialog').focus();
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('detail-modal').setAttribute('aria-hidden', 'true');
|
||
document.body.classList.remove('modal-open');
|
||
}
|
||
|
||
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';
|
||
} catch (error) {
|
||
document.getElementById('loading-msg').style.display = 'none';
|
||
document.getElementById('error-msg').style.display = 'block';
|
||
document.getElementById('error-msg').textContent = `加载失败: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
(async () => {
|
||
const missionSelect = document.getElementById('mission-select');
|
||
const missions = await loadMissions();
|
||
missions.forEach(mission => {
|
||
const option = document.createElement('option');
|
||
option.value = mission;
|
||
option.textContent = mission;
|
||
missionSelect.appendChild(option);
|
||
});
|
||
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const urlMission = urlParams.get('mission');
|
||
const urlAt = urlParams.get('at');
|
||
if (urlMission) missionSelect.value = urlMission;
|
||
if (urlAt) document.getElementById('time-input').value = urlAt;
|
||
await load(urlMission || null, urlAt || null);
|
||
|
||
document.getElementById('go-btn').addEventListener('click', () => {
|
||
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);
|
||
});
|
||
|
||
document.getElementById('detail-modal').addEventListener('click', event => {
|
||
if (event.target === event.currentTarget) closeModal();
|
||
});
|
||
document.querySelector('.modal-close').addEventListener('click', closeModal);
|
||
document.addEventListener('keydown', event => {
|
||
if (event.key === 'Escape' && document.getElementById('detail-modal').getAttribute('aria-hidden') === 'false') {
|
||
closeModal();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|